diff --git a/.cento/api_workers.yaml b/.cento/api_workers.yaml new file mode 100644 index 0000000..6ae120b --- /dev/null +++ b/.cento/api_workers.yaml @@ -0,0 +1,70 @@ +openai: + enabled: true + budget_usd_default: 10.00 + budget_usd_max: 20.00 + max_parallel_requests: 5 + timeout_seconds: 90 + retry_attempts: 1 + cost_usd_estimate_per_request: 0.20 + minimum_cost_usd_estimate_per_request: 0.05 + max_input_chars: 20000 + max_output_tokens: 2000 + notification_policy: muted + +profiles: + api-planner: + provider: openai + endpoint: responses + model: "${CENTO_OPENAI_PLANNER_MODEL}" + output_schema: workset_plan.v1 + cost_usd_estimate: 0.10 + max_input_chars: 16000 + max_output_tokens: 1500 + + api-section-worker: + provider: openai + endpoint: responses + model: "${CENTO_OPENAI_WORKER_MODEL}" + output_schema: docs_section.v1 + cost_usd_estimate: 0.20 + max_input_chars: 20000 + max_output_tokens: 2000 + + api-reviewer: + provider: openai + endpoint: responses + model: "${CENTO_OPENAI_REVIEWER_MODEL}" + output_schema: validation_review.v1 + cost_usd_estimate: 0.10 + max_input_chars: 12000 + max_output_tokens: 1200 + + api-mini-integrator: + provider: openai + endpoint: responses + model: "gpt-4.1-mini" + output_schema: validation_review.v1 + cost_usd_estimate: 0.08 + max_input_chars: 12000 + max_output_tokens: 1200 + + api-patch-proposal: + provider: openai + endpoint: responses + model: "gpt-4.1-mini" + output_schema: patch_proposal.v1 + cost_usd_estimate: 0.0125 + max_input_chars: 8000 + max_output_tokens: 900 + pricing: + input_usd_per_1m: 0.80 + output_usd_per_1m: 3.20 + + api-proreq-planner: + provider: openai + endpoint: responses + model: "${CENTO_OPENAI_PRO_MODEL}" + output_schema: hard_proreq_plan.v1 + cost_usd_estimate: 2.00 + max_input_chars: 50000 + max_output_tokens: 5000 diff --git a/.cento/builds/.gitkeep b/.cento/builds/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.cento/builds/.gitkeep @@ -0,0 +1 @@ + diff --git a/.cento/compute-policy.json b/.cento/compute-policy.json new file mode 100644 index 0000000..e35b1f1 --- /dev/null +++ b/.cento/compute-policy.json @@ -0,0 +1,73 @@ +{ + "schema_version": "cento.compute_policy.v1", + "profile": "codex-first", + "providers": { + "codex": { + "share": 85, + "kind": "agent", + "runtime": "codex", + "model": "gpt-5.3-codex-spark", + "cost_mode": "subscription_or_limit", + "enabled": true, + "notes": "Prefer Codex when interactive/agent limit is available." + }, + "claude": { + "share": 15, + "kind": "agent", + "runtime": "claude-code", + "model": "claude-sonnet-4-6", + "cost_mode": "subscription_or_limit", + "enabled": true, + "notes": "Fallback for agent work when Codex is unavailable or weighted routing selects it." + }, + "openai_api": { + "share": 0, + "kind": "api", + "runtime": "api-openai", + "model": "${CENTO_OPENAI_WORKER_MODEL}", + "cost_mode": "metered", + "enabled": false, + "notes": "Use only when a pipeline explicitly needs API-only behavior such as structured Responses or image generation." + } + }, + "updated_at": "2026-05-05T06:08:59Z", + "agent_runtime_weights": { + "codex": 85, + "claude-code": 15 + }, + "metered_api_policy": { + "openai_api_share": 0, + "prefer_agent_when_possible": true, + "requires_explicit_api_runtime": true, + "agent_preference_policy": { + "codex_claude_utilization_threshold_percent": 30, + "eligible_work_agent_preference_percent_range": [ + 70, + 80 + ], + "eligible_work_agent_preference_target_percent": 75, + "metered_openai_api_reserved_for": [ + "structured Responses API work", + "image generation", + "ProReq planning", + "other API-only behavior" + ], + "notes": "When Codex/Claude weekly utilization is above 30% and capacity remains usable, prefer agent lanes for roughly 70-80% of eligible non-API-only work." + } + }, + "agent_preference_policy": { + "codex_claude_utilization_threshold_percent": 30, + "eligible_work_agent_preference_percent_range": [ + 70, + 80 + ], + "eligible_work_agent_preference_target_percent": 75, + "metered_openai_api_reserved_for": [ + "structured Responses API work", + "image generation", + "ProReq planning", + "other API-only behavior" + ], + "notes": "When Codex/Claude weekly utilization is above 30% and capacity remains usable, prefer agent lanes for roughly 70-80% of eligible non-API-only work." + } +} diff --git a/.cento/modes.yaml b/.cento/modes.yaml new file mode 100644 index 0000000..de92634 --- /dev/null +++ b/.cento/modes.yaml @@ -0,0 +1,49 @@ +modes: + fast: + time_budget_minutes: 5 + validation_tier: smoke + info_policy: infer + ask_policy: blockers_only + commit_policy: none + push_policy: none + max_workers: 0 + max_files_changed: 3 + repair_attempts: 0 + risk_acceptance: medium + behavior: + - Patch the visible issue only. + - Infer missing details when the choice is reversible. + - Skip broad cleanup, refactors, PRs, and full regression. + + standard: + time_budget_minutes: 15 + validation_tier: focused + info_policy: ask_if_blocked + ask_policy: one_batch_if_material + commit_policy: local_commit + push_policy: optional + max_workers: 2 + max_files_changed: 8 + repair_attempts: 1 + risk_acceptance: low_medium + behavior: + - Make a scoped product-quality patch. + - Ask only if the wrong choice would waste work. + - Run targeted validation and commit owned paths when clean. + + thorough: + time_budget_minutes: 30 + validation_tier: product + info_policy: ask_first + ask_policy: requirements_or_options_first + commit_policy: local_commit + push_policy: branch + pr_policy: draft + max_workers: 4 + max_files_changed: null + repair_attempts: 3 + risk_acceptance: low + behavior: + - Plan first with options and budget. + - Use explicit manifests, workers, and validation evidence. + - Push a branch and prepare PR/taskstream evidence when requested. diff --git a/.cento/runtimes.yaml b/.cento/runtimes.yaml new file mode 100644 index 0000000..ade2f9d --- /dev/null +++ b/.cento/runtimes.yaml @@ -0,0 +1,65 @@ +runtimes: + codex-fast: + type: command + argv: + - codex + - exec + - -C + - "{worktree}" + - --sandbox + - workspace-write + - "-" + stdin_file: "{prompt}" + timeout_seconds: 180 + cwd: "{worktree}" + env_allowlist: + - PATH + - HOME + - LANG + - LC_ALL + - TERM + max_changed_files: 3 + max_patch_lines: 600 + allow_network: false + + claude-code-fast: + type: command + argv: + - claude + - -p + - --output-format + - text + stdin_file: "{prompt}" + timeout_seconds: 180 + cwd: "{worktree}" + env_allowlist: + - PATH + - HOME + - LANG + - LC_ALL + - TERM + max_changed_files: 3 + max_patch_lines: 600 + allow_network: false + + fixture-valid: + type: fixture + fixture_case: valid + timeout_seconds: 180 + max_changed_files: 3 + max_patch_lines: 600 + + python-fixture: + type: command + argv: + - python3 + - -c + - "import json, os; from pathlib import Path; p=Path(json.loads(os.environ['CENTO_ALLOWED_WRITE_PATHS'])[0]); s=p.read_text(); p.write_text(s.replace('are now', 'remain', 1) if 'are now' in s else s.rstrip() + '\\n\\n')" + timeout_seconds: 180 + cwd: "{worktree}" + env_allowlist: + - PATH + - HOME + max_changed_files: 3 + max_patch_lines: 600 + allow_network: false diff --git a/.cento/worksets/.gitkeep b/.cento/worksets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 6894e4f..3788de0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,18 @@ workspace/factory-integration-worktrees/* workspace/tmp/* !workspace/tmp/.gitkeep +.cento/builds/* +!.cento/builds/.gitkeep +.cento/worksets/* +!.cento/worksets/.gitkeep + logs/* experimental/redmine-career-consulting/data/postgres/ experimental/redmine-career-consulting/themes/ +.env +.env.* +!.env.example +!.env.mcp.example .env.mcp diff --git a/AGENTS.md b/AGENTS.md index cefe4bd..c15be14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,3 +15,10 @@ Repo guidance for AI agents working in `cento`: - Keep `data/cento-cli.json` aligned with `scripts/cento.sh` when the root CLI built-ins change. - For Zsh, Oh My Zsh, or tmux integration changes, read `docs/terminal-integration.md` and run `make terminal-e2e`. + +## ChatGPT Pro prompt bridge + +- When the operator asks to create a prompt for ChatGPT Pro or "smart bro" and copy it to the clipboard, use the stable bridge command exactly as `cento temp run`. +- Do not add an ID, suffix, postfix, or alternate wrapper for this workflow. Do not tell the operator to run `cento temp run ` for the ChatGPT Pro prompt bridge. +- Write or update the prompt Markdown under `workspace/runs/temp/chatgpt-pro/`, point the default temp command entry `cento-dev-scale-pro-prompt` at that Markdown file, run `cento temp run` automatically, and visibly report that the prompt was copied to the clipboard. +- `cento temp run ` is only for advanced one-off temp commands, not for the ChatGPT Pro prompt bridge. diff --git a/CLUSTER_NOTICE.md b/CLUSTER_NOTICE.md index bc20909..9b9446d 100644 --- a/CLUSTER_NOTICE.md +++ b/CLUSTER_NOTICE.md @@ -1,8 +1,8 @@ -# Cluster Notice: Cheap Spark Worker Pool +# Cluster Notice: Claude Worker Pool -Updated: 2026-04-30 +Updated: 2026-05-05 -Cheap Spark/Codex workers are now an explicit coordination option. +Claude Code workers are the active coordination option while Codex weekly limit is reserved for interactive coordination. Use this when there is queued work that is simple, bounded, validator-like, docs/evidence-oriented, or otherwise safe to delegate while the main agent keeps working. @@ -11,15 +11,15 @@ Use this when there is queued work that is simple, bounded, validator-like, docs Plan candidate work without mutating Redmine or starting agents: ```bash -cento agent-work dispatch-pool --limit 3 -cento agent-work dispatch-pool --limit 5 --json +cento agent-pool-kick --dry-run +cento agent-pool-kick --dry-run --max-launch 5 ``` -Defaults: +Current defaults: -- runtime: `codex` -- model: `gpt-5.3-codex-spark` -- mode: plan-only +- runtime: `claude-code` +- model: `claude-sonnet-4-6` +- mode: plan-only (`--dry-run`) - skips epics and non-dispatchable nodes unless explicitly overridden ## Start Workers @@ -27,13 +27,13 @@ Defaults: Only start workers when the plan looks reasonable: ```bash -cento agent-work dispatch-pool --limit 2 --execute +cento agent-pool-kick --max-launch 2 ``` For a specific package: ```bash -cento agent-work dispatch-pool --package industrial-panels-v1 --limit 2 --execute +cento agent-pool-kick --package industrial-panels-v1 --max-launch 2 ``` ## Encouraged Work Creation @@ -52,7 +52,7 @@ Keep tasks scoped and independently verifiable. Prefer `story.json`, `validation ## Guardrails -- `dispatch-pool` is safe by default; it does not launch anything without `--execute`. +- `agent-pool-kick` is safe by default (`--dry-run`); it does not launch anything unless `--dry-run` is omitted. - Use `cento agent-work runs --json --active` to see active workers. - Cross-node Linux runs are reconciled from Mac before being called stale. - Do not overwrite dirty node work. Sync through git or use temporary worktrees for validation. diff --git a/Makefile b/Makefile index 9171a00..4ed3436 100644 --- a/Makefile +++ b/Makefile @@ -8,23 +8,26 @@ DEVICE ?= CMD ?= pwd ARGS ?= -.PHONY: check tree index platforms inventory snapshot scaffold batch search bt-audio-doctor audio-quick-connect kitty-theme wallpaper display i3reorg dashboard preset bridge quick-help quick-help-fzf network network-tui jobs idea-board tg tui crm funnel funnel-check burp mcp scan cento redmine-e2e tracker-migrate agent-work-e2e agent-work-dual-backend-stress agent-work-app-start agent-work-app-stop agent-work-app-status agent-work-app-sync agent-manager agent-manager-janitor terminal-e2e industrial-e2e +.PHONY: check tree index platforms inventory snapshot scaffold batch search bt-audio-doctor audio-quick-connect kitty-theme wallpaper display i3reorg dashboard preset bridge quick-help quick-help-fzf network network-tui jobs idea-board tg tui crm funnel funnel-check burp mcp scan cento redmine-e2e tracker-migrate agent-work-e2e agent-work-dual-backend-stress agent-work-app-start agent-work-app-stop agent-work-app-status agent-work-app-sync agent-manager agent-manager-janitor terminal-e2e industrial-e2e test-patch-bundles patch-bundle-fixture test-release-candidate release-candidate-fixture test-taskstream-handoff taskstream-fixture check: - $(PYTHON) -m py_compile scripts/agent_coordinator.py scripts/agent_manager.py scripts/agent_pool_kick.py scripts/agent_work.py scripts/agent_work_app_contract_check.py scripts/agent_work_redmine_replacement_visual_validation.py scripts/agent_work_replacement_migration.py scripts/bluetooth_audio_doctor.py scripts/cento_interactive.py scripts/cento_mcp_server.py scripts/cluster_job_runner.py scripts/crm_module.py scripts/dashboard_server.py scripts/deliverables_hub.py scripts/docs_module_e2e.py scripts/factory.py scripts/factory_autopilot.py scripts/factory_autopilot_e2e.py scripts/factory_autopilot_policy.py scripts/factory_autopilot_policy_matrix_e2e.py scripts/factory_autopilot_render.py scripts/factory_autopilot_runtime_e2e.py scripts/factory_autopilot_state.py scripts/factory_console_e2e.py scripts/factory_dispatch_core.py scripts/factory_dispatch_e2e.py scripts/factory_e2e.py scripts/factory_integrate.py scripts/factory_integrated_validate.py scripts/factory_integration.py scripts/factory_integration_e2e.py scripts/factory_integration_state.py scripts/factory_integrator_core.py scripts/factory_merge_readiness.py scripts/factory_patch.py scripts/factory_plan.py scripts/factory_queue.py scripts/factory_registry_gate.py scripts/factory_release_candidate.py scripts/factory_render.py scripts/factory_runtime.py scripts/factory_runtime_adapters_e2e.py scripts/factory_rollback.py scripts/factory_taskstream_sync.py scripts/factory_validate.py scripts/funnel_check.py scripts/funnel_module.py scripts/gather_context.py scripts/idea_board_server.py scripts/incident_response.py scripts/industrial_activity.py scripts/industrial_activity_contract_check.py scripts/industrial_cluster_contract_check.py scripts/industrial_focus.py scripts/industrial_jobs_contract_check.py scripts/industrial_panel.py scripts/industrial_panel_actions_contract_check.py scripts/industrial_status.py scripts/jobs_server.py scripts/manifest_validate.py scripts/mcp_tooling.py scripts/network_web_server.py scripts/no_model_validate_contract_check.py scripts/no_model_validation_e2e.py scripts/platform_report.py scripts/research_map.py scripts/scan_onepager.py scripts/storage.py scripts/storage_e2e.py scripts/story_manifest.py scripts/story_screenshot_runner.py scripts/tool_index.py scripts/validation_manifest.py scripts/validator_tier0.py + $(PYTHON) -m py_compile scripts/agent_coordinator.py scripts/agent_manager.py scripts/agent_pool_kick.py scripts/agent_work.py scripts/agent_work_app_contract_check.py scripts/agent_work_redmine_replacement_visual_validation.py scripts/agent_work_replacement_migration.py scripts/bluetooth_audio_doctor.py scripts/cento_build.py scripts/cento_interactive.py scripts/cento_mcp_server.py scripts/cento_openai_worker.py scripts/cento_run_mode.py scripts/cento_runtime.py scripts/cento_workset.py scripts/cluster_job_runner.py scripts/crm_module.py scripts/dashboard_server.py scripts/deliverables_hub.py scripts/demo_evidence.py scripts/docs_module_e2e.py scripts/factory.py scripts/factory_autopilot.py scripts/factory_autopilot_e2e.py scripts/factory_autopilot_policy.py scripts/factory_autopilot_policy_matrix_e2e.py scripts/factory_autopilot_render.py scripts/factory_autopilot_runtime_e2e.py scripts/factory_autopilot_state.py scripts/factory_console_e2e.py scripts/factory_dispatch_core.py scripts/factory_dispatch_e2e.py scripts/factory_e2e.py scripts/factory_integrate.py scripts/factory_integrated_validate.py scripts/factory_integration.py scripts/factory_integration_e2e.py scripts/factory_integration_state.py scripts/factory_integrator_core.py scripts/factory_merge_readiness.py scripts/factory_patch.py scripts/factory_plan.py scripts/factory_queue.py scripts/factory_registry_gate.py scripts/factory_release_candidate.py scripts/factory_render.py scripts/factory_runtime.py scripts/factory_runtime_adapters_e2e.py scripts/factory_rollback.py scripts/factory_taskstream_sync.py scripts/factory_validate.py scripts/funnel_check.py scripts/funnel_module.py scripts/gather_context.py scripts/idea_board_server.py scripts/incident_response.py scripts/industrial_activity.py scripts/industrial_activity_contract_check.py scripts/industrial_cluster_contract_check.py scripts/industrial_focus.py scripts/industrial_focus_contract_check.py scripts/industrial_jobs_contract_check.py scripts/industrial_mission.py scripts/industrial_panel.py scripts/industrial_panel_actions_contract_check.py scripts/industrial_pet_contract_check.py scripts/industrial_status.py scripts/jobs_server.py scripts/manifest_validate.py scripts/mcp_tooling.py scripts/network_web_server.py scripts/no_model_validate_contract_check.py scripts/no_model_validation_e2e.py scripts/object_storage.py scripts/parallel_delivery_taskstream.py scripts/platform_report.py scripts/research_map.py scripts/scan_onepager.py scripts/storage.py scripts/storage_e2e.py scripts/story_manifest.py scripts/story_screenshot_runner.py scripts/tool_foundry.py scripts/tool_index.py scripts/validation_manifest.py scripts/validator_tier0.py $(PYTHON) scripts/agent_manager_contract_check.py $(PYTHON) scripts/no_model_validate_contract_check.py + $(PYTHON) scripts/industrial_focus_contract_check.py + $(PYTHON) scripts/industrial_pet_contract_check.py go build -o workspace/tmp/cento-interactive-check ./scripts/cento_interactive.go go build -o workspace/tmp/cento-daily-check ./scripts/daily_tui.go go build -o workspace/tmp/cento-industrial-aux-tui-check ./scripts/industrial_aux_tui.go go build -o workspace/tmp/cento-industrial-cluster-tui-check ./scripts/industrial_cluster_tui.go go build -o workspace/tmp/cento-industrial-jobs-tui-check ./scripts/industrial_jobs_tui.go + go build -o workspace/tmp/cento-industrial-pet-tui-check ./scripts/industrial_pet_tui.go go build -o workspace/tmp/cento-network-tui-check ./scripts/network_tui.go go build -o workspace/tmp/telegram-tui-check ./scripts/telegram_tui.go $(PYTHON) -c 'import json, pathlib; json.loads(pathlib.Path("data/tools.json").read_text()); json.loads(pathlib.Path(".mcp.json").read_text())' $(PYTHON) scripts/funnel_check.py ./scripts/industrial_panel_e2e.sh - bash -n scripts/agent_coordinator_daemon.sh scripts/agent_work_dual_backend_stress.sh scripts/agent_work_e2e.sh scripts/agent_work_hygiene.sh scripts/audio_quick_connect.sh scripts/batch_exec.sh scripts/bridge.sh scripts/burp_suite_community.sh scripts/cento.sh scripts/cento_interactive.sh scripts/cento_temp.sh scripts/cluster.sh scripts/cluster_activity_e2e.sh scripts/cluster_health_e2e.sh scripts/daily_tui.sh scripts/dashboard.sh scripts/display_layout_fix.sh scripts/i3reorg.sh scripts/idea_board.sh scripts/industrial_aux_tui.sh scripts/industrial_cluster_tui.sh scripts/industrial_codex_terminal.sh scripts/industrial_jobs_tui.sh scripts/industrial_macos_preset.sh scripts/industrial_os_preset.sh scripts/industrial_panel_e2e.sh scripts/industrial_workspace.sh scripts/install_linux.sh scripts/install_macos.sh scripts/ios_mobile_e2e.sh scripts/jobs.sh scripts/kitty_theme_manager.sh scripts/lib/common.sh scripts/mobile.sh scripts/network.sh scripts/network_tui.sh scripts/notify.sh scripts/opencode.sh scripts/preset.sh scripts/project_scaffold.sh scripts/quick_help.sh scripts/quick_help_fzf.sh scripts/redmine_workflow_e2e.sh scripts/repo_snapshot.sh scripts/restart_discord.sh scripts/search_report.sh scripts/system_inventory.sh scripts/telegram_tui.sh scripts/terminal_integration_e2e.sh scripts/wallpaper_manager.sh + bash -n scripts/agent_coordinator_daemon.sh scripts/agent_work_dual_backend_stress.sh scripts/agent_work_e2e.sh scripts/agent_work_hygiene.sh scripts/audio_quick_connect.sh scripts/batch_exec.sh scripts/bridge.sh scripts/burp_suite_community.sh scripts/cento.sh scripts/cento_interactive.sh scripts/cento_temp.sh scripts/cluster.sh scripts/cluster_activity_e2e.sh scripts/cluster_health_e2e.sh scripts/daily_tui.sh scripts/dashboard.sh scripts/display_layout_fix.sh scripts/i3reorg.sh scripts/idea_board.sh scripts/industrial_aux_tui.sh scripts/industrial_cluster_tui.sh scripts/industrial_codex_terminal.sh scripts/industrial_jobs_tui.sh scripts/industrial_macos_preset.sh scripts/industrial_os_preset.sh scripts/industrial_panel_e2e.sh scripts/industrial_pet_tui.sh scripts/industrial_workspace.sh scripts/install_linux.sh scripts/install_macos.sh scripts/ios_mobile_e2e.sh scripts/jobs.sh scripts/kitty_theme_manager.sh scripts/lib/common.sh scripts/mobile.sh scripts/network.sh scripts/network_tui.sh scripts/notify.sh scripts/opencode.sh scripts/preset.sh scripts/project_scaffold.sh scripts/quick_help.sh scripts/quick_help_fzf.sh scripts/redmine_workflow_e2e.sh scripts/repo_snapshot.sh scripts/restart_discord.sh scripts/search_report.sh scripts/system_inventory.sh scripts/telegram_tui.sh scripts/terminal_integration_e2e.sh scripts/wallpaper_manager.sh zsh -n scripts/completion/_cento industrial-e2e: @@ -66,6 +69,27 @@ kitty-theme: cento: ./scripts/cento.sh $(ARGS) +test-patch-bundles: + $(PYTHON) -m pytest -q tests/test_patch_bundle_validation.py tests/test_patch_bundle_collector.py + +patch-bundle-fixture: + $(PYTHON) scripts/parallel_delivery/patch_bundle_fixture.py --out workspace/runs/parallel-delivery/patch-bundle-fixture --base-commit "$$(git rev-parse HEAD)" + ./scripts/cento.sh parallel-delivery patch-bundles collect --run-id patch-bundle-fixture --bundles-dir workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles --lease-manifest workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json --out workspace/runs/parallel-delivery/patch-bundle-fixture --base-commit "$$(git rev-parse HEAD)" --json + +test-release-candidate: + $(PYTHON) -m pytest -q tests/test_parallel_delivery_safe_apply.py tests/test_parallel_delivery_release_candidate.py + +release-candidate-fixture: + $(PYTHON) scripts/parallel_delivery/release_candidate_fixture.py --out workspace/runs/parallel-delivery/release-candidate-fixture --base-commit "$$(git rev-parse HEAD)" + ./scripts/cento.sh parallel-delivery release-candidate create --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json --out workspace/runs/parallel-delivery/release-candidate-fixture/dry-run --mode dry-run --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo --base-commit "$$(git rev-parse HEAD)" --json + +test-taskstream-handoff: + $(PYTHON) -m pytest -q tests/test_parallel_delivery_taskstream.py tests/test_parallel_delivery_agent_work_manifests.py + +taskstream-fixture: + $(PYTHON) scripts/parallel_delivery/taskstream_fixture.py --out workspace/runs/parallel-delivery/taskstream-fixture --base-commit "$$(git rev-parse HEAD)" + ./scripts/cento.sh parallel-delivery taskstream emit --split-plan workspace/runs/parallel-delivery/taskstream-fixture/input/split-plan.json --out workspace/runs/parallel-delivery/taskstream-fixture --transport manifest-only --run-preflight + tracker-migrate: $(PYTHON) scripts/migrate_redmine_to_tracker.py $(ARGS) @@ -139,10 +163,10 @@ agent-work-dual-backend-stress: ./scripts/agent_work_dual_backend_stress.sh $(ARGS) agent-work-app-start: - ./scripts/cento.sh agent-work-app start + $(PYTHON) scripts/agent_work_app.py start agent-work-app-stop: - ./scripts/cento.sh agent-work-app stop + $(PYTHON) scripts/agent_work_app.py stop agent-work-app-status: curl -fsS http://127.0.0.1:47910/health diff --git a/README.md b/README.md index f97a6dd..0238219 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ The bias is toward low-dependency tooling that works well from a terminal and ca Detect two monitors, stack them vertically, and repair your xrandr layout. - `i3reorg.sh` Keep numeric i3 workspaces on the bottom monitor, move common windows to preferred workspaces, and place the study YouTube window on L2. +- `industrial_pet_tui.sh` + Open the Darth Lolipopus Cute Sith pet pane with the same portrait art used by the rofi launcher. +- `industrial_pet_tui.go` + Bubble Tea implementation for the persistent Darth Lolipopus Tamagotchi pane. - `quick_help.sh` Open a rofi-style searchable help palette for cento commands, tools, and aliases. - `system_inventory.sh` @@ -105,8 +109,24 @@ The bias is toward low-dependency tooling that works well from a terminal and ca Generate `docs/tool-index.md` from the central registry. - `factory.py` Create no-model Factory runs with intake artifacts, validated `factory-plan.json`, story manifests, validation manifests, queue ledgers, owned-path lease simulation, worktree metadata, prompt bundles, patch collection, integration dry-runs, safe factory integration branches, rollback metadata, release candidates, release status, and static evidence hubs. +- `cento_build.py` + Create manifest-owned build packages, run one local fixture or runtime-profile command builder, check worker artifacts, synthesize patch bundles, dry-run integration in an isolated worktree, apply accepted bundles, and write validation/integration/apply/evidence receipts. +- `cento_runtime.py` + Inspect and validate `.cento/runtimes.yaml` profiles for hardened local builder execution. +- `cento_workset.py` + Run small exclusive-path local worksets with parallel worker patch collection, structured API artifact workers, simple dependency gates, budget caps, and sequential integration/apply. +- `parallel_delivery_patch_bundles.py` + Collect local Patch Swarm worker patch bundles or evidence-only outputs, validate them against authoritative leases, reject unsafe diffs before integration, and write receipts/reports without applying patches. +- `parallel_delivery_release_candidate.py` + Read accepted Parallel Delivery integration receipts, verify accepted bundle receipts and patch hashes, dry-run or safely apply bundles in isolated targets, and write apply reports, rollback metadata, release notes, and release-candidate artifacts. +- `parallel_delivery_taskstream.py` + Convert Patch Swarm split plans into local `agent-work` story and validation manifests, handoff notes, command previews, preflight reports, and apply-gated Taskstream receipts without direct database writes. +- `parallel_delivery_patch_swarm_console.py` + Read Patch Swarm run artifacts and render `console-data.json` plus a static `start-here.html` status hub with relative evidence links. - `storage.py` Catalog Cento run artifacts into SQLite, classify evidence/logs/screenshots/patches/manifests, plan no-delete lifecycle actions, verify hashes, and render storage reports before high-fanout Factory runs create artifact pressure. +- `object_storage.py` + Write a run-scoped dummy file and mirror Cento run images to private Oracle Object Storage with the OCI CLI, including explicit region support, content-addressed receipts, verification, and dry-run paths. ## Common commands @@ -174,6 +194,24 @@ make cento ARGS="factory release-candidate factory-integration-e2e" make cento ARGS="factory sync-taskstream factory-integration-e2e --dry-run" make cento ARGS="factory release workspace/runs/factory/factory-planning-e2e --json" make cento ARGS="factory render-hub workspace/runs/factory/factory-planning-e2e" +make cento ARGS='run fast --task "Patch docs page title" --write apps/watch/KanjiADay/Preview/index.html --route /docs/apps/kanji-a-day' +make cento ARGS='run fast --task "Patch docs page title" --write apps/watch/KanjiADay/Preview/index.html --route /docs/apps/kanji-a-day --local-builder fixture --fixture-case valid --apply --validation smoke --commit none' +make cento ARGS='runtime check codex-fast' +make cento ARGS='run fast --task "Patch docs page title" --write apps/watch/KanjiADay/Preview/index.html --route /docs/apps/kanji-a-day --runtime-profile codex-fast --apply --validation smoke --commit none' +make cento ARGS="workset check tests/fixtures/cento_workset/workset.valid.json" +make cento ARGS="workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke" +make cento ARGS="workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke" +make cento ARGS="workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke" +make cento ARGS="build artifact check tests/fixtures/cento_build/worker_artifact.valid.json" +make cento ARGS="build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff" +make cento ARGS="build integrate tests/fixtures/cento_build/manifest.valid.json --bundle .cento/builds/build_fixture_docs_page_001/integration/patch_bundle.json --dry-run" +make patch-bundle-fixture +make test-patch-bundles +make release-candidate-fixture +make test-release-candidate +make taskstream-fixture +make test-taskstream-handoff +make cento ARGS="parallel-delivery patch-swarm status --run-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 --write-html --json" make cento ARGS="storage scan --root workspace/runs --db workspace/storage/catalog.sqlite" make cento ARGS="storage plan --dry-run" make cento ARGS="storage query --largest --limit 20" @@ -217,6 +255,7 @@ make cento ARGS="dark" ./scripts/wallpaper_manager.sh --choose ./scripts/display_layout_fix.sh --show ./scripts/i3reorg.sh --dry-run +./scripts/industrial_pet_tui.sh --once --width 98 --height 24 ./scripts/dashboard_server.py ./scripts/cluster.sh plan "implement feature A" ./scripts/cluster.sh implement "implement feature A" --dry-run @@ -350,6 +389,8 @@ cento audio-quick-connect "Black Diamond" cento audio "Black Diamond" cento dashboard cento dashboard --open +cento industrial-pet +cento industrial-pet --action nap cento jobs cento jobs --open cento idea-board @@ -731,8 +772,11 @@ The Industrial OS preset: - writes managed Polybar, Rofi, and Picom files under `~/.config/cento/industrial-os/` - adds a guarded block to `~/.config/i3/config` so i3 reloads start the preset session - keeps dashboard startup on the explicit `--dashboard-only --open` path -- binds `Mod+Shift+I` to compose workspace 1 into the Discord, hero, terminal, jobs, cluster, activity, and quick-actions layout with background images on every generated pane +- binds `Mod+Shift+I` to compose workspace 1 into the Discord, hero, terminal, Darth Lolipopus pet, cluster, activity, and quick-actions layout with background images on every generated pane - supports `cento preset industrial-os --workspace --black-only` to compose the same workspace with plain black pane backgrounds +- persists the Darth Lolipopus pet state under `${XDG_STATE_HOME:-~/.local/state}/cento/industrial-os/darth-lolipopus.json` +- uses `assets/industrial-os/darth-lolipopus.png` as the in-pane pet portrait, matching the rofi launcher side art +- uses `assets/industrial-os/darth-lolipopus-pane.png` for the live Industrial OS pet tile so the portrait stays bitmap-sharp instead of terminal-pixelated - routes `Mod+h/j/k/l` through a visual focus helper on the Industrial OS cockpit, with normal i3 focus behavior as fallback elsewhere - writes preset logs to `logs/industrial-os/` and workspace compose logs to `logs/industrial-workspace/` diff --git a/apps/watch/KanjiADay/KanjiADay.xcodeproj/project.pbxproj b/apps/watch/KanjiADay/KanjiADay.xcodeproj/project.pbxproj index 8b100ea..d238d9d 100644 --- a/apps/watch/KanjiADay/KanjiADay.xcodeproj/project.pbxproj +++ b/apps/watch/KanjiADay/KanjiADay.xcodeproj/project.pbxproj @@ -84,7 +84,7 @@ ); productName = KanjiADay; productReference = 6B0000010000000000000010 /* KanjiADay.app */; - productType = "com.apple.product-type.application.watchapp2"; + productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -194,7 +194,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.willingtodev.KanjiADay.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; - SKIP_INSTALL = NO; + SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; @@ -215,7 +215,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.willingtodev.KanjiADay.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; - SKIP_INSTALL = NO; + SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; diff --git a/apps/watch/KanjiADay/KanjiADay/ContentView.swift b/apps/watch/KanjiADay/KanjiADay/ContentView.swift index 711f798..2cc9ee6 100644 --- a/apps/watch/KanjiADay/KanjiADay/ContentView.swift +++ b/apps/watch/KanjiADay/KanjiADay/ContentView.swift @@ -20,6 +20,8 @@ struct ContentView: View { TodayView( kanji: store.currentKanji, streak: store.progress.streak, + position: store.currentPosition, + totalCount: store.totalCount, onStart: startPractice ) case .strokes: @@ -72,6 +74,8 @@ struct ContentView: View { struct TodayView: View { let kanji: DailyKanji let streak: Int + let position: Int + let totalCount: Int let onStart: () -> Void var body: some View { @@ -97,7 +101,17 @@ struct TodayView: View { .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Color(red: 1.0, green: 0.34, blue: 0.22)) - Spacer(minLength: 4) + VStack(spacing: 3) { + Text(kanji.reading) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.78)) + + Text("\(position) of \(totalCount)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.52)) + } + + Spacer(minLength: 2) Button(action: onStart) { Text("Let's learn") @@ -125,6 +139,9 @@ struct StrokePracticeView: View { .font(.system(size: 13, weight: .bold)) .foregroundStyle(Color(red: 1.0, green: 0.34, blue: 0.22)) Spacer() + Text(kanji.reading) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.58)) Button(action: onReplay) { Image(systemName: "arrow.clockwise") } @@ -165,6 +182,16 @@ struct MeaningView: View { .font(.system(size: 16, weight: .bold)) .foregroundStyle(Color(red: 1.0, green: 0.34, blue: 0.22)) + Text(kanji.reading) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.78)) + + Text(kanji.example) + .font(.system(size: 12, weight: .semibold)) + .multilineTextAlignment(.center) + .foregroundStyle(.white.opacity(0.66)) + .minimumScaleFactor(0.8) + Text("Current streak \(streak) day\(streak == 1 ? "" : "s")") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.64)) diff --git a/apps/watch/KanjiADay/KanjiADay/Info.plist b/apps/watch/KanjiADay/KanjiADay/Info.plist index d8a19e5..e8f1811 100644 --- a/apps/watch/KanjiADay/KanjiADay/Info.plist +++ b/apps/watch/KanjiADay/KanjiADay/Info.plist @@ -4,7 +4,21 @@ CFBundleDisplayName Kanji a Day + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) WKApplication + WKWatchOnly + diff --git a/apps/watch/KanjiADay/KanjiADay/Kanji.swift b/apps/watch/KanjiADay/KanjiADay/Kanji.swift index b108553..3ea30fe 100644 --- a/apps/watch/KanjiADay/KanjiADay/Kanji.swift +++ b/apps/watch/KanjiADay/KanjiADay/Kanji.swift @@ -13,6 +13,8 @@ struct KanjiStroke: Identifiable, Hashable { struct DailyKanji: Identifiable, Hashable { let id: String let meaning: String + let reading: String + let example: String let strokes: [KanjiStroke] } @@ -21,6 +23,8 @@ enum KanjiDataset { DailyKanji( id: "日", meaning: "sun, day", + reading: "ニチ / ひ", + example: "日曜日 - Sunday", strokes: [ KanjiStroke(id: 1, points: [StrokePoint(x: 0.30, y: 0.18), StrokePoint(x: 0.30, y: 0.82)]), KanjiStroke(id: 2, points: [StrokePoint(x: 0.30, y: 0.18), StrokePoint(x: 0.72, y: 0.18), StrokePoint(x: 0.72, y: 0.82)]), @@ -31,6 +35,8 @@ enum KanjiDataset { DailyKanji( id: "月", meaning: "moon, month", + reading: "ゲツ / つき", + example: "月曜日 - Monday", strokes: [ KanjiStroke(id: 1, points: [StrokePoint(x: 0.34, y: 0.16), StrokePoint(x: 0.34, y: 0.86)]), KanjiStroke(id: 2, points: [StrokePoint(x: 0.34, y: 0.16), StrokePoint(x: 0.72, y: 0.16), StrokePoint(x: 0.72, y: 0.86)]), @@ -41,12 +47,65 @@ enum KanjiDataset { DailyKanji( id: "火", meaning: "fire", + reading: "カ / ひ", + example: "火曜日 - Tuesday", strokes: [ KanjiStroke(id: 1, points: [StrokePoint(x: 0.40, y: 0.26), StrokePoint(x: 0.24, y: 0.52)]), KanjiStroke(id: 2, points: [StrokePoint(x: 0.63, y: 0.24), StrokePoint(x: 0.76, y: 0.52)]), KanjiStroke(id: 3, points: [StrokePoint(x: 0.52, y: 0.16), StrokePoint(x: 0.50, y: 0.48), StrokePoint(x: 0.32, y: 0.84)]), KanjiStroke(id: 4, points: [StrokePoint(x: 0.52, y: 0.48), StrokePoint(x: 0.76, y: 0.84)]) ] + ), + DailyKanji( + id: "水", + meaning: "water", + reading: "スイ / みず", + example: "水曜日 - Wednesday", + strokes: [ + KanjiStroke(id: 1, points: [StrokePoint(x: 0.50, y: 0.16), StrokePoint(x: 0.50, y: 0.86)]), + KanjiStroke(id: 2, points: [StrokePoint(x: 0.28, y: 0.36), StrokePoint(x: 0.42, y: 0.50), StrokePoint(x: 0.24, y: 0.72)]), + KanjiStroke(id: 3, points: [StrokePoint(x: 0.70, y: 0.32), StrokePoint(x: 0.56, y: 0.52)]), + KanjiStroke(id: 4, points: [StrokePoint(x: 0.55, y: 0.52), StrokePoint(x: 0.76, y: 0.80)]) + ] + ), + DailyKanji( + id: "木", + meaning: "tree, wood", + reading: "モク / き", + example: "木曜日 - Thursday", + strokes: [ + KanjiStroke(id: 1, points: [StrokePoint(x: 0.24, y: 0.38), StrokePoint(x: 0.78, y: 0.38)]), + KanjiStroke(id: 2, points: [StrokePoint(x: 0.51, y: 0.16), StrokePoint(x: 0.51, y: 0.86)]), + KanjiStroke(id: 3, points: [StrokePoint(x: 0.50, y: 0.40), StrokePoint(x: 0.26, y: 0.78)]), + KanjiStroke(id: 4, points: [StrokePoint(x: 0.52, y: 0.40), StrokePoint(x: 0.78, y: 0.78)]) + ] + ), + DailyKanji( + id: "金", + meaning: "gold, money", + reading: "キン / かね", + example: "金曜日 - Friday", + strokes: [ + KanjiStroke(id: 1, points: [StrokePoint(x: 0.52, y: 0.14), StrokePoint(x: 0.28, y: 0.34)]), + KanjiStroke(id: 2, points: [StrokePoint(x: 0.52, y: 0.14), StrokePoint(x: 0.78, y: 0.34)]), + KanjiStroke(id: 3, points: [StrokePoint(x: 0.34, y: 0.38), StrokePoint(x: 0.70, y: 0.38)]), + KanjiStroke(id: 4, points: [StrokePoint(x: 0.40, y: 0.54), StrokePoint(x: 0.64, y: 0.54)]), + KanjiStroke(id: 5, points: [StrokePoint(x: 0.28, y: 0.72), StrokePoint(x: 0.76, y: 0.72)]), + KanjiStroke(id: 6, points: [StrokePoint(x: 0.42, y: 0.58), StrokePoint(x: 0.34, y: 0.68)]), + KanjiStroke(id: 7, points: [StrokePoint(x: 0.60, y: 0.58), StrokePoint(x: 0.70, y: 0.68)]), + KanjiStroke(id: 8, points: [StrokePoint(x: 0.52, y: 0.38), StrokePoint(x: 0.52, y: 0.84)]) + ] + ), + DailyKanji( + id: "土", + meaning: "earth, soil", + reading: "ド / つち", + example: "土曜日 - Saturday", + strokes: [ + KanjiStroke(id: 1, points: [StrokePoint(x: 0.28, y: 0.42), StrokePoint(x: 0.74, y: 0.42)]), + KanjiStroke(id: 2, points: [StrokePoint(x: 0.51, y: 0.18), StrokePoint(x: 0.51, y: 0.76)]), + KanjiStroke(id: 3, points: [StrokePoint(x: 0.22, y: 0.78), StrokePoint(x: 0.80, y: 0.78)]) + ] ) ] } diff --git a/apps/watch/KanjiADay/KanjiADay/KanjiStore.swift b/apps/watch/KanjiADay/KanjiADay/KanjiStore.swift index b938d8c..38aa0f9 100644 --- a/apps/watch/KanjiADay/KanjiADay/KanjiStore.swift +++ b/apps/watch/KanjiADay/KanjiADay/KanjiStore.swift @@ -17,6 +17,14 @@ final class KanjiStore: ObservableObject { KanjiDataset.all[progress.currentIndex % KanjiDataset.all.count] } + var currentPosition: Int { + (progress.currentIndex % KanjiDataset.all.count) + 1 + } + + var totalCount: Int { + KanjiDataset.all.count + } + init(defaults: UserDefaults = .standard) { self.defaults = defaults if @@ -39,8 +47,9 @@ final class KanjiStore: ObservableObject { guard progress.lastOpenedDay != today else { return } - progress.currentIndex = (progress.currentIndex + 1) % KanjiDataset.all.count - progress.streak += 1 + let elapsedDays = max(Self.daysBetween(progress.lastOpenedDay, today), 1) + progress.currentIndex = (progress.currentIndex + elapsedDays) % KanjiDataset.all.count + progress.streak = elapsedDays == 1 ? progress.streak + 1 : 1 progress.lastOpenedDay = today save() } @@ -60,4 +69,22 @@ final class KanjiStore: ObservableObject { formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: date) } + + private static func daysBetween(_ startKey: String, _ endKey: String) -> Int { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + + guard + let startDate = formatter.date(from: startKey), + let endDate = formatter.date(from: endKey) + else { + return 1 + } + + let calendar = Calendar(identifier: .gregorian) + return calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 1 + } } diff --git a/apps/watch/KanjiADay/Preview/index.html b/apps/watch/KanjiADay/Preview/index.html index 6bea830..d60b1cb 100644 --- a/apps/watch/KanjiADay/Preview/index.html +++ b/apps/watch/KanjiADay/Preview/index.html @@ -3,210 +3,1348 @@ - Kanji a Day MVP Preview + Kanji a Day -
-
-
-

Kanji a Day MVP

-
Today, stroke order, meaning. Three kanji. Watch-only.
+
+
+
+
+
+ 0 / 4 + Today +
+ Shape first +
+ ready + 0:00 +
+
+ +
+ +
+ +
+ +
+ + +
+
+
+ +
+

Kanji a Day

+

One kanji. Every day.

+

Learn stroke order, meaning, and reading in under a minute.

+ +
+ + +
+ +
+ + -
0 AI calls · no backend
-
-
-
-
-
10:09
-
Today
-
-
sun, day
-
Let's learn
-
-
Open once a day and get exactly one kanji.
-
-
-
-
10:09
-
3 / 4
- - - - - - -
Meaning
-
-
Follow the stroke order in a short animation.
-
-
-
-
10:09
-
Meaning
-
-
sun, day
-
Current streak 1 day
-
✓ Got it
-
-
Reveal meaning and preserve the daily streak.
-
+ + diff --git a/apps/watch/KanjiADay/README.md b/apps/watch/KanjiADay/README.md index cd44868..ebb32e2 100644 --- a/apps/watch/KanjiADay/README.md +++ b/apps/watch/KanjiADay/README.md @@ -4,11 +4,12 @@ Standalone watchOS SwiftUI MVP for the daily kanji loop. ## Scope -- 3 embedded kanji: 日, 月, 火. +- 7 embedded beginner kanji: 日, 月, 火, 水, 木, 金, 土. - Today screen. - Stroke-order animation screen. -- Meaning screen. +- Meaning screen with reading and example vocabulary. - Local `UserDefaults` state for `currentIndex`, `streak`, and `lastOpenedDay`. +- Missed days reset the streak instead of counting as consecutive practice. Out of scope for MVP: iPhone companion, translations, premium paywall, notifications, backend sync, and analytics. @@ -18,8 +19,8 @@ Out of scope for MVP: iPhone companion, translations, premium paywall, notificat xcodebuild \ -project apps/watch/KanjiADay/KanjiADay.xcodeproj \ -scheme KanjiADay \ - -destination 'platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)' \ + -destination 'platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.4' \ build ``` -This Linux node cannot run `xcodebuild`; use the Mac node or open the project in Xcode. +Linux nodes cannot run `xcodebuild`; use the Mac node or open the project in Xcode. diff --git a/assets/industrial-os/darth-lolipopus-pane.png b/assets/industrial-os/darth-lolipopus-pane.png new file mode 100644 index 0000000..1a3813f Binary files /dev/null and b/assets/industrial-os/darth-lolipopus-pane.png differ diff --git a/assets/industrial-os/darth-lolipopus.png b/assets/industrial-os/darth-lolipopus.png new file mode 100644 index 0000000..2d55265 Binary files /dev/null and b/assets/industrial-os/darth-lolipopus.png differ diff --git a/data/agent-runtimes.json b/data/agent-runtimes.json index 7f7b5e5..054d160 100644 --- a/data/agent-runtimes.json +++ b/data/agent-runtimes.json @@ -7,11 +7,11 @@ "provider": "openai", "model": "gpt-5.3-codex-spark", "agent": "codex", - "weight": 75, + "weight": 85, "preferred": true, "command_env": "CENTO_CODEX_BIN", "default_binary": "codex", - "budget_note": "Preferred runtime. Majority share because Codex budget is about 100 USD/month." + "budget_note": "Compute policy `codex-first` assigns Codex share 85." }, { "id": "claude-code", @@ -19,11 +19,19 @@ "provider": "anthropic", "model": "claude-sonnet-4-6", "agent": "claude-code", - "weight": 25, + "weight": 15, "plan": "personal-pro", "command_env": "CENTO_CLAUDE_BIN", "default_binary": "claude", - "budget_note": "Personal Pro plan budget is about 20-30 USD/month, so route about 20-30% of tasks here." + "budget_note": "Compute policy `codex-first` assigns Claude share 15.", + "preferred": false } - ] + ], + "compute_policy": { + "schema_version": "cento.compute_policy.v1", + "profile": "codex-first", + "policy_path": "/home/alice/projects/cento/.cento/compute-policy.json", + "applied_at": "2026-05-05T06:08:59Z", + "openai_api_share": 0 + } } diff --git a/data/cento-cli.json b/data/cento-cli.json index 579e9a7..a3f357c 100644 --- a/data/cento-cli.json +++ b/data/cento-cli.json @@ -6,18 +6,51 @@ "Registered tools can be invoked directly as `cento TOOL [args...]`.", "Configured aliases can be invoked directly as `cento ALIAS [args...]`.", "Use `cento docs` or `cento interactive` when you need the built-in command surface explained from the canonical JSON source.", + "When an operator asks to save something in Docs, default to human-facing files under `docs/` and update navigation when discoverability matters; command-reference docs are a separate surface.", "`cento install terminal` installs the managed Zsh/Oh My Zsh completion init plus the Cento prompt segment." ], + "checklist": [ + { + "name": "Discover", + "summary": "Start with `cento docs`, `cento tools`, and a repo search before adding a new command or workflow." + }, + { + "name": "Task", + "summary": "For Cento feature, automation, MCP, cluster, mobile, UI, or command behavior changes, create an `agent-work` story manifest and task before implementation." + }, + { + "name": "Align", + "summary": "Keep `data/cento-cli.json`, affected docs in `docs/`, and any generated indexes aligned with the actual command surface." + }, + { + "name": "Validate", + "summary": "Run the narrow deterministic checks for the files changed, including JSON validation for docs sources." + }, + { + "name": "Evidence", + "summary": "Leave validation evidence in the relevant `workspace/runs/agent-work//` bundle and update Taskstream status." + } + ], "routing": [ { "name": "tool-routing", "usage": "cento TOOL [args...]", - "summary": "Run a registered tool directly by id, including `cento factory` for plan, dispatch, and Safe Integrator artifacts." + "summary": "Run a registered tool directly by id, including `cento build` for manifest-owned local worker patch contracts and `cento factory` for plan, dispatch, and Safe Integrator artifacts." }, { "name": "alias-routing", "usage": "cento ALIAS [args...]", "summary": "Run a configured alias directly from `~/.config/cento/aliases.sh`." + }, + { + "name": "routing-nativeness-loop", + "usage": "cento walk-autopilot routing run --json", + "summary": "Collect counts-only routing, observability, Agent Work, skill usage, and cento-native drift stats; write a decision report; and create or update one bounded Agent Work follow-up without implementing from cron." + }, + { + "name": "temp-clipboard-routing", + "usage": "cento temp run", + "summary": "Route operator clipboard/reference copy requests to the fixed pbcopy wrapper only. The command accepts no ids, flags, or alternate temp entries; change the copied Markdown by editing `COPY_FILE` in scripts/cento_temp.sh." } ], "commands": [ @@ -150,19 +183,130 @@ }, { "name": "run", - "summary": "Run a registered tool by id.", - "usage": "cento run TOOL [args...]", - "flags": [], + "summary": "Run a registered tool by id, or create a fast/standard/thorough execution contract that routes owned-path patch work through Build rather than a new worker primitive.", + "usage": "cento run TOOL [args...] | cento run fast|standard|thorough --task TEXT [--write PATH] [--local-builder [RUNTIME] --apply]", + "flags": [ + { + "name": "--mode fast|standard|thorough", + "summary": "Create an execution-mode contract without using the positional mode form.", + "usage": "cento run --mode fast --task \"Fix app docs page\" --write apps/foo/index.html" + }, + { + "name": "--task TEXT", + "summary": "Operator task statement for execution-mode contracts.", + "usage": "cento run fast --task \"Fix app docs page\"" + }, + { + "name": "--write PATH", + "summary": "Owned writable path for the generated contract. Repeatable.", + "usage": "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html" + }, + { + "name": "--local-builder [RUNTIME]", + "summary": "Run one local builder runtime, defaulting to the deterministic fixture runtime when no value is supplied.", + "usage": "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --local-builder fixture" + }, + { + "name": "--fixture-case valid|unowned|protected|delete|lockfile|binary", + "summary": "Select the deterministic fixture worker case.", + "usage": "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --local-builder fixture --fixture-case valid" + }, + { + "name": "--builder-command TEXT", + "summary": "Unsafe raw command template used when --local-builder command is selected; prefer --runtime-profile.", + "usage": "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --local-builder command --builder-command \"codex exec --prompt-file {prompt}\" --allow-unsafe-command" + }, + { + "name": "--runtime-profile NAME", + "summary": "Run one local builder from a named profile in .cento/runtimes.yaml.", + "usage": "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --runtime-profile codex-fast --apply" + }, + { + "name": "--apply", + "summary": "Apply the accepted local-builder patch bundle to the operator worktree.", + "usage": "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --local-builder fixture --fixture-case valid --apply" + } + ], "examples": [ "cento run scan --query \"mcp\"", "cento run crm docs", - "cento run factory status workspace/runs/factory/factory-planning-e2e" + "cento run factory status workspace/runs/factory/factory-planning-e2e", + "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo", + "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo --runtime-profile codex-fast --apply --validation smoke --commit none", + "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo --local-builder fixture --fixture-case valid --apply --validation smoke --commit none", + "cento run --mode standard --task \"Polish Kanji docs\" --write templates/agent-work-app/index.html --validation focused" + ], + "details": [ + "`cento run fast --task ... --write PATH` creates the execution contract and an implicit `.cento/builds//` manifest and Builder prompt. Without `--local-builder` or `--runtime-profile`, the integration receipt remains pending. With `--runtime-profile codex-fast --apply` or `--local-builder fixture --fixture-case valid --apply`, Cento launches one isolated local builder, collects a patch bundle, dry-runs integration, applies the accepted patch, runs smoke validation, and writes Taskstream evidence." + ] + }, + { + "name": "build", + "summary": "Registered tool route for the Build patch unit and safety substrate: owned paths, Builder prompts, patch bundles, dry-run integration, safe apply, and receipts.", + "usage": "cento build [args...]", + "flags": [], + "examples": [ + "cento build init --task \"Fixture docs page patch\" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture", + "cento build check tests/fixtures/cento_build/manifest.valid.json", + "cento build prompt tests/fixtures/cento_build/manifest.valid.json", + "cento build artifact check tests/fixtures/cento_build/worker_artifact.valid.json", + "cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180", + "cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime-profile codex-fast --worktree", + "cento runtime check codex-fast", + "cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff", + "cento build integrate .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --worktree --dry-run", + "cento build apply .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --from-receipt .cento/builds//integration_receipt.json", + "cento build receipt .cento/builds/build_fixture_docs_page_001" + ], + "details": [ + "This command is routed through data/tools.json and scripts/cento_build.py.", + "Build v1.2 is local-only and deterministic. It is the canonical patch unit and safety substrate for Cento/Patch Swarm pilots: manifests, Builder prompts, fixture/local isolated workers, artifact checks, patch bundles, raw patch rejection outside dev mode, dirty-owned-path rejection, unowned/protected/hostile path rejection, hardened runtime profiles, isolated dry-run integration, apply only from accepted integration receipts, and validation/integration/apply/evidence receipts. Cloud workers, API calls, schedulers, PRs, and automatic model patch generation remain outside Build." + ] + }, + { + "name": "runtime", + "summary": "Registered tool route for local builder runtime profile inspection.", + "usage": "cento runtime [args...]", + "flags": [], + "examples": [ + "cento runtime list", + "cento runtime check codex-fast", + "cento runtime check codex-fast --json", + "cento runtime check python-fixture --require-executable" + ], + "details": [ + "Runtime profiles live in `.cento/runtimes.yaml` and define argv-array command runtimes or deterministic fixture profiles.", + "`cento runtime check` validates the profile shape and reports executable availability without launching a worker." + ] + }, + { + "name": "workset", + "summary": "Registered tool route for Workset parallel lease semantics: exclusive-path local N-worker worksets, dependency gates, structured API artifacts, and sequential integration.", + "usage": "cento workset [args...]", + "flags": [], + "examples": [ + "cento workset check tests/fixtures/cento_workset/workset.valid.json", + "cento workset check tests/fixtures/cento_workset/workset.execute.api.json --runtime api-openai", + "cento workset check tests/fixtures/cento_workset/workset.overlap.json", + "cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke", + "cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke", + "cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke", + "cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke", + "cento workset materialize-artifact .cento/worksets//workers//artifact.json" + ], + "details": [ + "Workset v1 is the canonical parallel lease substrate for Cento/Patch Swarm pilots. It requires exclusive write_paths for each task. No shared files, overlapping paths, or glob write paths are accepted.", + "Plain `cento workset check WORKSET` rejects missing write paths. API-worker-created file plans must declare `--runtime api-openai` or `--allow-creates`.", + "Workers run in parallel only for patch or structured artifact collection. Integration and apply are always sequential.", + "OpenAI API workers use Responses API structured outputs and never mutate repo files directly.", + "API worker budgets have a target and hard max; budget-blocked workers still write cost receipts.", + "Simple depends_on gates are supported; a task dispatches only after dependencies are completed and applied." ] }, { "name": "factory", - "summary": "Registered tool route for the no-model Cento Factory planning, dispatch dry-run, and Safe Integrator workflow.", - "usage": "cento factory [args...]", + "summary": "Registered tool route for the Factory orchestration substrate: intake, planning, materialization, queueing, dry-run dispatch, validation, integration, release candidates, and hubs.", + "usage": "cento factory [args...]", "flags": [], "examples": [ "cento factory intake \"develop me a career consulting module\" --dry-run --out workspace/runs/factory/factory-planning-e2e", @@ -177,8 +321,11 @@ "cento factory integrate factory-integration-e2e --plan", "cento factory integrate factory-integration-e2e --prepare-branch --branch factory/factory-integration-e2e/integration", "cento factory integrate factory-integration-e2e --apply --validate-each --limit 3", + "cento factory validate-fanout factory-integration-e2e --max-parallel 32 --json", "cento factory validate-integrated factory-integration-e2e", "cento factory release-candidate factory-integration-e2e", + "cento factory merge factory-integration-e2e --auto-merge-main --dry-run --json", + "cento factory merge factory-integration-e2e --auto-merge-main --push --json", "cento factory sync-taskstream factory-integration-e2e --dry-run", "cento factory release workspace/runs/factory/factory-planning-e2e --json", "cento factory render-hub workspace/runs/factory/factory-planning-e2e", @@ -194,7 +341,81 @@ ], "details": [ "This command is routed through data/tools.json and scripts/factory.py.", - "Factory defaults to deterministic no-model planning, queueing, lease simulation, dry-run dispatch, patch collection, Safe Integrator branch/apply/validate gates, rollback metadata, merge readiness, release evidence, Autopilot dry-run control cycles, and runtime adapter contracts. Live Taskstream creation requires --apply, while integration Taskstream sync is a dry-run preview by default." + "Factory defaults to deterministic no-model planning, queueing, lease simulation, dry-run dispatch, patch collection, Safe Integrator branch/apply/validate gates, rollback metadata, merge readiness, release evidence, Autopilot dry-run control cycles, and runtime adapter contracts. Factory is the preferred execution spine for real Patch Swarm pilots; live Taskstream creation requires --apply, while integration Taskstream sync is a dry-run preview by default.", + "Factory validate-fanout runs cacheable candidate checks in parallel before serialized Safe Integrator apply.", + "Factory merge --auto-merge-main is the only automatic main/push gate and requires release, rollback, validation, clean-worktree, and post-merge receipts.", + "Factory merge --auto-merge-main --dry-run writes merge readiness evidence without merging or pushing." + ] + }, + { + "name": "object-storage", + "summary": "Registered tool route for Oracle Object Storage dummy uploads and Cento image mirroring.", + "usage": "cento object-storage [args...]", + "flags": [ + { + "name": "--bucket", + "summary": "OCI Object Storage bucket name. Defaults to CENTO_OBJECT_STORAGE_BUCKET.", + "usage": "cento object-storage put-dummy --bucket my-bucket" + }, + { + "name": "--namespace", + "summary": "OCI Object Storage namespace. Defaults to CENTO_OBJECT_STORAGE_NAMESPACE or OCI CLI auto-discovery.", + "usage": "cento object-storage put-dummy --namespace mynamespace" + }, + { + "name": "--region", + "summary": "OCI region for Object Storage calls, for example us-ashburn-1.", + "usage": "cento object-storage e2e --live --region us-ashburn-1" + }, + { + "name": "--name", + "summary": "Bucket name for ensure-bucket.", + "usage": "cento object-storage ensure-bucket --name cento-images-standard" + }, + { + "name": "--dry-run", + "summary": "Do not call OCI; copy the dummy file into the run-scoped uploaded directory.", + "usage": "cento object-storage put-dummy --dry-run" + }, + { + "name": "--live", + "summary": "Run the e2e through the live OCI CLI upload path.", + "usage": "cento object-storage e2e --live --bucket my-bucket" + }, + { + "name": "--json", + "summary": "Print machine-readable JSON.", + "usage": "cento object-storage e2e --json" + }, + { + "name": "--manifest", + "summary": "Image migration manifest or upload receipt path.", + "usage": "cento object-storage upload-images --manifest workspace/runs/object-storage//manifest.json" + }, + { + "name": "--sample", + "summary": "Number of unique uploaded image objects to verify; 0 means all.", + "usage": "cento object-storage verify-images --manifest workspace/runs/object-storage//upload-receipt.json --sample 10" + } + ], + "examples": [ + "cento object-storage status", + "cento object-storage status --probe --json", + "cento object-storage ensure-bucket --name cento-images-standard --region us-ashburn-1 --namespace mynamespace --json", + "cento object-storage put-dummy --dry-run --json", + "cento object-storage put-dummy --region us-ashburn-1 --bucket my-bucket --namespace mynamespace --json", + "cento object-storage e2e --json", + "cento object-storage e2e --live --region us-ashburn-1 --bucket my-bucket --namespace mynamespace --json", + "cento object-storage plan-images --root workspace/runs --bucket cento-images-standard --namespace mynamespace --region us-ashburn-1 --json", + "cento object-storage upload-images --manifest workspace/runs/object-storage//manifest.json --live --json", + "cento object-storage verify-images --manifest workspace/runs/object-storage//upload-receipt.json --sample 10 --json" + ], + "details": [ + "This command is routed through data/tools.json and scripts/object_storage.py.", + "The MVP writes workspace/runs/object-storage//dummy.txt, records receipt.json and summary.md, and uploads exactly one text object through `oci os object put` when live mode is configured.", + "Image migration writes a mirror-only manifest for workspace run images, blocks sensitive-looking paths, uploads content-addressed objects, and verifies downloads by sha256.", + "Dry-run image upload copies files under the run-scoped uploaded directory; live mode requires a private Standard OCI bucket.", + "Human runbook: docs/oci-image-migration.html; Markdown source: docs/oci-image-migration.md" ] }, { diff --git a/data/industrial-pet.json b/data/industrial-pet.json new file mode 100644 index 0000000..9ecfd6e --- /dev/null +++ b/data/industrial-pet.json @@ -0,0 +1,188 @@ +{ + "schema_version": 1, + "activities": [ + { + "id": "sith_snack", + "name": "Sith snack", + "description": "Offer a tiny dark-side lollipop ration.", + "deltas": { + "snack": 24, + "energy": 5, + "menace": 3, + "affection": 3 + }, + "comments": [ + "Darth Lolipopus crunches the ration and declares it almost worthy.", + "Sugar rises. The tiny Sith points at the next victim.", + "A sticky little Force choke is attempted on the snack wrapper." + ], + "log": [ + "snack accepted with imperial suspicion", + "ration consumed; cape crumbs detected", + "lollipop reserves briefly stabilized" + ] + }, + { + "id": "duel_practice", + "name": "Duel practice", + "description": "Run a careful saber drill with soft targets.", + "deltas": { + "snack": -8, + "energy": -16, + "menace": 22, + "affection": 2 + }, + "comments": [ + "Darth Lolipopus bonks the training dummy and bows to nobody.", + "The duel is short, dramatic, and mostly aimed at furniture.", + "A two-inch saber flourish leaves the room politically unstable." + ], + "log": [ + "duel drill completed; dummy humbled", + "tiny saber routine improved", + "training target accepted defeat" + ] + }, + { + "id": "nap", + "name": "Nap", + "description": "Lower the lights and let the Sith recharge.", + "deltas": { + "snack": -4, + "energy": 26, + "menace": -5, + "affection": 4 + }, + "comments": [ + "Darth Lolipopus naps with one helmeted eye open.", + "The cockpit goes quiet except for tiny ominous breathing.", + "A blanket is accepted as tribute, not comfort." + ], + "log": [ + "nap cycle completed", + "rest restored; blanket classified as tribute", + "helmet breathing softened for a moment" + ] + }, + { + "id": "cape_compliment", + "name": "Cape compliment", + "description": "Praise the cape without sounding insufficiently afraid.", + "deltas": { + "snack": 0, + "energy": 0, + "menace": 6, + "affection": 18 + }, + "comments": [ + "Darth Lolipopus pretends not to enjoy the cape review.", + "The cape swishes. Morale improves. So does tyranny.", + "A proud little turn confirms the compliment landed." + ], + "log": [ + "cape compliment logged", + "swish quality acknowledged", + "vanity reserves increased" + ] + }, + { + "id": "helmet_polish", + "name": "Helmet polish", + "description": "Buff the tiny helmet until the cockpit glows.", + "deltas": { + "snack": -2, + "energy": 0, + "menace": 8, + "affection": 10 + }, + "comments": [ + "Darth Lolipopus inspects the shine and sees a future empire.", + "Helmet polish complete. The reflection looks extremely serious.", + "The tiny mask gleams with unreasonable authority." + ], + "log": [ + "helmet polish passed inspection", + "mask shine restored", + "reflection intimidation increased" + ] + }, + { + "id": "tiny_mission", + "name": "Tiny mission", + "description": "Dispatch Darth Lolipopus on a low-risk cockpit patrol.", + "deltas": { + "snack": -6, + "energy": -10, + "menace": 16, + "affection": 6 + }, + "comments": [ + "Darth Lolipopus returns with a button, a glare, and no explanation.", + "The tiny mission succeeds. Nobody admits what it was.", + "A secret patrol concludes with suspiciously sticky evidence." + ], + "log": [ + "tiny mission returned with classified loot", + "cockpit patrol completed", + "mission success filed under adorable menace" + ] + } + ], + "mood_comments": { + "hangry": [ + "The tiny Sith taps the snack meter with theatrical menace.", + "Darth Lolipopus is hungry enough to negotiate poorly." + ], + "sleepy": [ + "Helmet tilt indicates the dark side requires a nap.", + "Darth Lolipopus is too tired for conquest, but not for judgment." + ], + "sulking": [ + "A small cape turn communicates serious displeasure.", + "Darth Lolipopus requires recognition before further operations." + ], + "dramatic": [ + "The room is 12 percent more imperial than it was a moment ago.", + "Darth Lolipopus has entered maximum cape mode." + ], + "smug": [ + "Darth Lolipopus radiates sticky, well-fed superiority.", + "A tiny nod suggests the cockpit may continue existing." + ], + "scheming": [ + "Darth Lolipopus quietly reorganizes the galaxy by snack priority.", + "A small Sith plan is forming near the status LEDs." + ] + }, + "idle_barks": [ + "Darth Lolipopus waits beside the console with suspicious patience.", + "The tiny Sith checks the room for insufficient loyalty.", + "A little cape swish marks the passage of time." + ], + "rare_events": [ + { + "id": "red_sugar_comet", + "one_in": 17, + "text": "rare event: red sugar comet sighted over the cockpit", + "comment": "Darth Lolipopus absorbs comet vibes and becomes briefly magnificent.", + "deltas": { + "snack": 8, + "energy": 8, + "menace": 8, + "affection": 8 + } + }, + { + "id": "mini_force_wobble", + "one_in": 23, + "text": "rare event: a tiny Force wobble rearranged the cape hooks", + "comment": "Darth Lolipopus denies all responsibility while standing near the evidence.", + "deltas": { + "snack": -2, + "energy": -2, + "menace": 12, + "affection": 4 + } + } + ] +} diff --git a/data/patch-swarm-pro-calls.json b/data/patch-swarm-pro-calls.json new file mode 100644 index 0000000..5e93071 --- /dev/null +++ b/data/patch-swarm-pro-calls.json @@ -0,0 +1,3941 @@ +{ + "schema_version": "cento.patch_swarm.pro_call_registry.v1", + "program": "Patch Swarm Real Pilot Pro Calls", + "source": "operator_supplied_new_call_spec_contract_calls_00_30_2026-05-13", + "created_at": "2026-05-13T23:35:16Z", + "updated_at": "2026-05-13T23:41:09Z", + "status_lifecycle": [ + "PENDING", + "IN_PROGRESS", + "CODEX_DONE", + "CLOSED", + "BLOCKED" + ], + "defaults": { + "live_pro_default": false, + "live_workers_default": false, + "taskstream_mutation_default": false, + "patch_apply_default": false, + "evidence_root": "workspace/runs/parallel-delivery/", + "prompt_policy": "Prompt stays empty until the operator provides the exact call prompt.", + "pro_output_policy": "Pro_output stays empty until the operator provides model output; ingest raw text verbatim with the helper." + }, + "parts": [ + { + "part": 1, + "calls": "00-30", + "name": "Reuse-first real pilot through existing surfaces" + }, + { + "part": 2, + "calls": "31-60", + "name": "Worker runtime and patch loop, adapter-first placeholders" + }, + { + "part": 3, + "calls": "61-100", + "name": "Mission Control, eval lab, and scale proof placeholders" + } + ], + "calls": [ + { + "call_id": 0, + "call_label": "CALL 00", + "part": 1, + "title": "Evidence Lineage Resolver", + "status": "PENDING", + "prompt": "## CALL 00: Evidence Lineage Resolver\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nEvidence lineage tracing from existing Patch Swarm / Parallel Delivery closeout artifacts into a reusable pilot intake chain.\n\n**Operator-visible outcome:**\nThe operator can ask: “What existing evidence can seed the next real pilot?” and get a repo-backed answer instead of a manually guessed starting point.\n\n**Scaling/safety/reuse bottleneck addressed:**\nRemoves manual archaeology across closeout summaries, QA summaries, release receipts, and demo evidence.\n\n**Why this is not orchestration theater:**\nThis call proves whether existing evidence artifacts can be consumed as input to the next execution path. It does not create a new workflow.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing `parallel-delivery`, `demo-evidence`, `temp`, and evidence directories first. Do not create a new evidence registry.\n\n**Codex must:**\n\n* Resolve the latest existing closeout, final QA, release candidate, and demo evidence artifacts.\n* Identify which artifacts can become inputs to Factory / Build / Workset / Agent Work.\n* Produce an evidence lineage map showing artifact → owner surface → next consumer.\n* Mark missing or incompatible artifacts as reuse breaks, not as reasons to invent new schemas.\n\n**Codex must not:**\n\n* Create a new evidence format.\n* Create a new ledger.\n* Hardcode absolute paths, timestamps, run IDs, or local filenames.\n* Pretend an artifact exists if it cannot be resolved from repo state.\n\n**Existing surface that should own the work:**\n`parallel-delivery` for product-facing lineage, `demo-evidence` and `temp` for operator evidence utilities.\n\n**Implementation / adapter allowance:**\nRead-only resolver only. A tiny adapter is allowed only if existing evidence exists but no registered surface exposes it cleanly.\n\n**Evidence to write:**\n`/evidence-lineage-map.md`\n`/resolved-placeholders.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /evidence-lineage-map.md\ntest -s /resolved-placeholders.json\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n./cento.sh demo-evidence --help\n```\n\n**Acceptance:**\n\n* At least one prior evidence artifact is resolved, or `` explains why none can be resolved.\n* Every resolved artifact has an owner surface and proposed next consumer.\n* No new schema or fixture is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Evidence lineage tracing from existing Patch Swarm / Parallel Delivery closeout artifacts into a reusable pilot intake chain.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/evidence-lineage-map.md", + "/resolved-placeholders.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-000-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 1, + "call_label": "CALL 01", + "part": 1, + "title": "Closeout-to-Factory Intake Probe", + "status": "PENDING", + "prompt": "## CALL 01: Closeout-to-Factory Intake Probe\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nCloseout-to-intake reuse for converting prior Patch Swarm evidence into a Factory-backed pilot candidate.\n\n**Operator-visible outcome:**\nThe operator can reuse a prior closeout as the seed for a new Factory pilot instead of writing a fresh task plan manually.\n\n**Scaling/safety/reuse bottleneck addressed:**\nReduces repeated prompt planning and prevents closeout evidence from becoming dead paperwork.\n\n**Why this is not orchestration theater:**\nThe call attempts to feed real prior evidence into the existing Factory substrate and records the exact break if Factory cannot consume it.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : symbolic Factory intake candidate derived from closeout evidence.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nFactory owns intake and orchestration substrate. Consume existing closeout evidence before writing any new Factory input artifact.\n\n**Codex must:**\n\n* Inspect existing Factory intake expectations from registered repo surfaces.\n* Attempt to map `` and `` into a Factory-compatible pilot intake.\n* Keep the pilot low-risk and repo-local.\n* Prefer an improvement that strengthens Patch Swarm / Parallel Delivery itself, such as status bridging, evidence lineage, dry-run planning, or receipt provenance.\n* Write a clear compatibility result.\n\n**Codex must not:**\n\n* Create a new Factory schema unless Factory explicitly requires one and no existing artifact can be consumed.\n* Invent a new Patch Swarm intake format.\n* Use hardcoded paths, timestamps, or local run IDs.\n* Select a fake pilot that only exercises fixtures.\n\n**Existing surface that should own the work:**\n`factory`.\n\n**Implementation / adapter allowance:**\nOnly a minimal closeout-to-Factory adapter may be proposed. Do not implement it unless the existing Factory command fails on a real artifact and the adapter is the smallest bridge.\n\n**Evidence to write:**\n`/closeout-to-factory-intake-probe.md`\n`/factory-intake-candidate.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /closeout-to-factory-intake-probe.md\ntest -s /reuse-gate.md\n./cento.sh factory --help\npython3 -m json.tool /factory-intake-candidate.json >/dev/null\n```\n\n**Acceptance:**\n\n* A Factory intake candidate is produced from existing evidence, or a reuse break explains why Factory cannot consume the evidence.\n* The selected pilot improves a real Patch Swarm / Parallel Delivery execution path.\n* No duplicate orchestration primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Closeout-to-intake reuse for converting prior Patch Swarm evidence into a Factory-backed pilot candidate.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/closeout-to-factory-intake-probe.md", + "/factory-intake-candidate.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 0 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-001-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 2, + "call_label": "CALL 02", + "part": 1, + "title": "Responsibility Ownership Enforcement Probe", + "status": "PENDING", + "prompt": "## CALL 02: Responsibility Ownership Enforcement Probe\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nResponsibility ownership enforcement across `parallel-delivery`, `factory`, `build`, `workset`, and `agent-work`.\n\n**Operator-visible outcome:**\nThe operator can see which surface owns each part of the pilot before work starts, preventing duplicate primitives.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents Parallel Delivery from silently becoming a second Factory, Build, Workset, or Agent Work system.\n\n**Why this is not orchestration theater:**\nThis call blocks architectural drift before the pilot runs and produces concrete owner mappings used by later calls.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : Factory intake candidate from CALL 01.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse `data/tools.json`, `data/cento-cli.json`, and any existing responsibility audit artifacts before proposing ownership changes.\n\n**Codex must:**\n\n* Map each pilot responsibility to the existing owning surface.\n* Identify any responsibility that currently has no clear owner.\n* Flag any place where `parallel-delivery` appears to own duplicate primitives.\n* Produce an owner enforcement checklist that later calls must follow.\n\n**Codex must not:**\n\n* Create new ownership categories.\n* Move responsibilities without evidence.\n* Implement a new dashboard or registry.\n* Treat documentation-only ownership as sufficient if commands/artifacts disagree.\n\n**Existing surface that should own the work:**\n`parallel-delivery` as facade, with ownership delegated to `factory`, `build`, `workset`, and `agent-work`.\n\n**Implementation / adapter allowance:**\nRead-only audit. Adapter allowance is limited to recommending a facade-to-owner routing shim if an existing command cannot be reached.\n\n**Evidence to write:**\n`/responsibility-owner-map.md`\n`/duplicate-primitive-risks.md`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s data/tools.json\ntest -s data/cento-cli.json\ntest -s /responsibility-owner-map.md\ntest -s /duplicate-primitive-risks.md\npython3 -m json.tool data/tools.json >/dev/null\npython3 -m json.tool data/cento-cli.json >/dev/null\n```\n\n**Acceptance:**\n\n* Every pilot responsibility has an owner surface or a precise reuse break.\n* Duplicate primitive risks are explicitly listed.\n* Later calls can use this owner map as a constraint.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Responsibility ownership enforcement across `parallel-delivery`, `factory`, `build`, `workset`, and `agent-work`.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/responsibility-owner-map.md", + "/duplicate-primitive-risks.md", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 1 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-002-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 3, + "call_label": "CALL 03", + "part": 1, + "title": "Pilot Selection for Real Patch Swarm Improvement", + "status": "PENDING", + "prompt": "## CALL 03: Pilot Selection for Real Patch Swarm Improvement\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nPilot selection anchored to a concrete Patch Swarm / Parallel Delivery capability.\n\n**Operator-visible outcome:**\nThe operator gets one selected low-risk pilot with clear owner surfaces, expected artifacts, and pass/fail criteria.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents the 0–30 sequence from becoming generic planning detached from real repo progress.\n\n**Why this is not orchestration theater:**\nThis call selects one real improvement to run through Factory, Build, Workset, Agent Work, and Parallel Delivery rather than inventing a fake exercise.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : Factory intake candidate from CALL 01.\n* : selected pilot spec for CALLS 05–30.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nSelect a pilot that can be represented by existing Factory/Build/Workset/Agent Work artifacts. Prefer a Parallel Delivery facade adapter over a new system.\n\n**Codex must:**\n\n* Evaluate candidate pilots from prior evidence.\n* Select exactly one low-risk pilot.\n* Prefer one of these real improvements if supported by repo state: Factory-to-Parallel-Delivery status bridge; Build receipt provenance surfaced in Parallel Delivery; Workset lease introspection surfaced through Parallel Delivery; Dry-run launch plan summarized by Parallel Delivery.\n* Explain why the selected pilot is safe, bounded, and useful.\n\n**Codex must not:**\n\n* Select a generic evidence inventory task.\n* Select a pilot requiring live dispatch.\n* Select a pilot requiring new runtime universe.\n* Select more than one primary pilot.\n\n**Existing surface that should own the work:**\n`factory` owns orchestration, `parallel-delivery` owns facade, `build` owns patch units, `workset` owns leases, `agent-work` owns lifecycle.\n\n**Implementation / adapter allowance:**\nOnly smallest adapter needed to expose existing owned artifacts through the facade.\n\n**Evidence to write:**\n`/selected-pilot-spec.md`\n`/selected-pilot-artifact-map.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /selected-pilot-spec.md\ntest -s /selected-pilot-artifact-map.json\npython3 -m json.tool /selected-pilot-artifact-map.json >/dev/null\n./cento.sh factory --help\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* Exactly one pilot is selected.\n* The pilot is tied to at least one concrete capability anchor.\n* The pilot can either proceed through existing surfaces or has a precise reuse break.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Pilot selection anchored to a concrete Patch Swarm / Parallel Delivery capability.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/selected-pilot-spec.md", + "/selected-pilot-artifact-map.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 2 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-003-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 4, + "call_label": "CALL 04", + "part": 1, + "title": "Pilot Boundary and Live-Dispatch-Off Plan", + "status": "PENDING", + "prompt": "## CALL 04: Pilot Boundary and Live-Dispatch-Off Plan\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nDry-run launch planning with explicit live dispatch disabled.\n\n**Operator-visible outcome:**\nThe operator gets a safe pilot boundary: what will run, what will not run, what artifacts will be created, and how dispatch remains off.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents uncontrolled worker execution while still exercising the real execution spine.\n\n**Why this is not orchestration theater:**\nThe plan becomes the actual control boundary for Calls 05–30 and constrains real Factory/Build/Workset/Agent Work reuse.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : selected pilot spec from CALL 03.\n* : live-dispatch-off pilot boundary.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing Factory dry-run, agent-pool visibility, and agent-process visibility controls before proposing a new dispatch flag.\n\n**Codex must:**\n\n* Define the dry-run-only execution boundary.\n* Identify every command that could dispatch live workers.\n* Confirm how the pilot will avoid live dispatch.\n* Define expected artifacts for Factory, Build, Workset, Agent Work, and Parallel Delivery.\n* Produce a call-by-call continuation handoff for CALL 05.\n\n**Codex must not:**\n\n* Start live workers.\n* Create or mutate external queues.\n* Add a new dispatch system.\n* Replace existing dry-run controls with a new flag unless no existing control exists.\n\n**Existing surface that should own the work:**\n`factory`, `agent-pool-kick`, `agent-processes`, and `parallel-delivery`.\n\n**Implementation / adapter allowance:**\nA guard adapter is allowed only if an existing dry-run mode cannot be enforced or displayed.\n\n**Evidence to write:**\n`/dry-run-boundary.md`\n`/dispatch-risk-map.md`\n`/call05-handoff.md`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /dry-run-boundary.md\ntest -s /dispatch-risk-map.md\ntest -s /call05-handoff.md\n./cento.sh factory --help\n./cento.sh agent-pool-kick --help\n./cento.sh agent-processes --help\n```\n\n**Acceptance:**\n\n* Live dispatch is explicitly disabled or blocked.\n* Expected artifacts for the pilot are named symbolically.\n* CALL 05 can begin from a concrete handoff.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Dry-run launch planning with explicit live dispatch disabled.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/dry-run-boundary.md", + "/dispatch-risk-map.md", + "/call05-handoff.md", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 3 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-004-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 5, + "call_label": "CALL 05", + "part": 1, + "title": "Factory Intake Reuse Dry Run", + "status": "PENDING", + "prompt": "## CALL 05: Factory Intake Reuse Dry Run\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nFactory intake reuse for the selected Patch Swarm pilot.\n\n**Operator-visible outcome:**\nThe operator can turn selected pilot evidence into a Factory-recognized execution intake or see the exact reuse break.\n\n**Scaling/safety/reuse bottleneck addressed:**\nMoves from prompt planning into the actual Factory orchestration substrate.\n\n**Why this is not orchestration theater:**\nThis call tries to use existing Factory intake behavior on a real pilot artifact, with live dispatch off.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : selected pilot spec from CALL 03.\n* : live-dispatch-off boundary from CALL 04.\n* : Factory intake artifact for this pilot.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nFactory owns intake. Use the existing Factory intake command/artifact before creating any adapter.\n\n**Codex must:**\n\n* Resolve the selected pilot and dry-run boundary.\n* Attempt to create or register a Factory intake using existing Factory commands.\n* Keep the intake tied to existing artifacts, not a synthetic fixture.\n* Record the exact Factory command attempted.\n* If Factory accepts the intake, write where it stored the resulting artifact.\n\n**Codex must not:**\n\n* Add a new intake schema.\n* Add a new queue.\n* Dispatch live workers.\n* Write an intake that bypasses Factory.\n\n**Existing surface that should own the work:**\n`factory`.\n\n**Implementation / adapter allowance:**\nOnly a closeout/pilot-spec-to-existing-Factory-intake adapter if Factory cannot consume the selected pilot artifact directly.\n\n**Evidence to write:**\n`/factory-intake-result.md`\n`/factory-command-transcript.txt`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /factory-intake-result.md\ntest -s /factory-command-transcript.txt\n./cento.sh factory --help\n```\n\n**Acceptance:**\n\n* Existing Factory intake is used successfully, or a precise reuse break is written.\n* The result points to a real Factory artifact.\n* The pilot remains dry-run-only.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Factory intake reuse for the selected Patch Swarm pilot.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/factory-intake-result.md", + "/factory-command-transcript.txt", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 4 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-005-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 6, + "call_label": "CALL 06", + "part": 1, + "title": "Factory Materialization for Selected Pilot", + "status": "PENDING", + "prompt": "## CALL 06: Factory Materialization for Selected Pilot\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nFactory materialization of a selected Patch Swarm pilot into bounded execution units.\n\n**Operator-visible outcome:**\nThe operator can see the selected pilot materialized as Factory-owned work, not as loose prompts.\n\n**Scaling/safety/reuse bottleneck addressed:**\nConverts one high-level improvement into bounded units without creating a duplicate Patch Swarm planner.\n\n**Why this is not orchestration theater:**\nThis call uses Factory as the execution spine and produces real Factory artifacts or a concrete break.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : Factory intake artifact from CALL 05.\n* : Factory materialization output directory.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing Factory materialization behavior. Do not create a second planner under `parallel-delivery`.\n\n**Codex must:**\n\n* Use existing Factory commands to materialize ``.\n* Confirm generated work units are bounded and low-risk.\n* Confirm generated units map to existing owner surfaces.\n* Record artifact paths symbolically.\n* Stop if Factory cannot materialize without inventing new schemas.\n\n**Codex must not:**\n\n* Create new task schema.\n* Create new manifest format unless Factory already uses it.\n* Create a new queue system.\n* Add live worker dispatch.\n\n**Existing surface that should own the work:**\n`factory`.\n\n**Implementation / adapter allowance:**\nOnly a minimal adapter that translates the selected pilot into an existing Factory-supported format.\n\n**Evidence to write:**\n`/factory-materialization-result.md`\n`/factory-work-units.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /factory-materialization-result.md\ntest -s /factory-work-units.json\npython3 -m json.tool /factory-work-units.json >/dev/null\n./cento.sh factory --help\n```\n\n**Acceptance:**\n\n* Factory materializes at least one bounded pilot work unit, or writes a precise reuse break.\n* Every unit maps to an owner surface.\n* No duplicate planner is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Factory materialization of a selected Patch Swarm pilot into bounded execution units.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/factory-materialization-result.md", + "/factory-work-units.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 5 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-006-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 7, + "call_label": "CALL 07", + "part": 1, + "title": "Factory Queue Generation with Workset Compatibility", + "status": "PENDING", + "prompt": "## CALL 07: Factory Queue Generation with Workset Compatibility\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nFactory queue generation compatible with Workset-owned parallel leases.\n\n**Operator-visible outcome:**\nThe operator can see which Factory units are ready for parallel lease handling.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents Factory-created work from becoming disconnected from the existing Workset lease substrate.\n\n**Why this is not orchestration theater:**\nThe call attempts to bridge real Factory materialized units into existing lease semantics.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : Factory materialization output from CALL 06.\n* : Workset-compatible queue plan.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nFactory owns generated work; Workset owns parallel leases. Use existing Workset lease artifacts rather than creating a Patch Swarm queue.\n\n**Codex must:**\n\n* Identify Factory work units eligible for parallel handling.\n* Attempt to map them to Workset lease expectations.\n* Produce a queue/lease compatibility report.\n* Identify owned paths or collision hints if available.\n* Record exact reuse break if Workset cannot consume Factory units.\n\n**Codex must not:**\n\n* Create a new queue.\n* Create a new lease model.\n* Duplicate Workset behavior in Parallel Delivery.\n* Dispatch workers.\n\n**Existing surface that should own the work:**\n`factory` and `workset`.\n\n**Implementation / adapter allowance:**\nOnly a Factory-unit-to-Workset-lease adapter if existing Workset cannot directly consume Factory units.\n\n**Evidence to write:**\n`/workset-queue-compatibility.md`\n`/workset-queue-plan.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /workset-queue-compatibility.md\ntest -s /workset-queue-plan.json\npython3 -m json.tool /workset-queue-plan.json >/dev/null\n./cento.sh workset --help\n```\n\n**Acceptance:**\n\n* Workset compatibility is proven for at least one Factory unit, or a precise reuse break is written.\n* No new queue or lease primitive is introduced.\n* Collision/owned-path implications are captured if available.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Factory queue generation compatible with Workset-owned parallel leases.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/workset-queue-compatibility.md", + "/workset-queue-plan.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 6 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-007-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 8, + "call_label": "CALL 08", + "part": 1, + "title": "Dry-Run Dispatch Plan Through Existing Runtime Visibility", + "status": "PENDING", + "prompt": "## CALL 08: Dry-Run Dispatch Plan Through Existing Runtime Visibility\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nDry-run launch planning with runtime visibility through existing `agent-pool-kick` and `agent-processes`.\n\n**Operator-visible outcome:**\nThe operator can preview what would be dispatched without starting workers.\n\n**Scaling/safety/reuse bottleneck addressed:**\nSeparates safe launch planning from live execution, making future parallel runs auditable.\n\n**Why this is not orchestration theater:**\nThis call exercises existing dispatch visibility surfaces against the real pilot plan, live dispatch off.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : Workset queue plan from CALL 07.\n* : dispatch preview with live execution disabled.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing runtime visibility surfaces before creating any new dispatch preview command.\n\n**Codex must:**\n\n* Produce a dry-run dispatch plan from existing Factory/Workset artifacts.\n* Use existing worker visibility commands to show what would happen.\n* Confirm no worker process is started.\n* Record every command that would start live dispatch and why it was not run.\n\n**Codex must not:**\n\n* Start workers.\n* Add a new runtime ledger.\n* Create a new worker visibility dashboard.\n* Simulate success without querying existing surfaces.\n\n**Existing surface that should own the work:**\n`agent-pool-kick`, `agent-processes`, `factory`, and `workset`.\n\n**Implementation / adapter allowance:**\nOnly a read-only preview adapter if existing commands expose worker state but not pilot-specific dry-run mapping.\n\n**Evidence to write:**\n`/dry-run-dispatch-plan.md`\n`/runtime-visibility-transcript.txt`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /dry-run-dispatch-plan.md\ntest -s /runtime-visibility-transcript.txt\n./cento.sh agent-pool-kick --help\n./cento.sh agent-processes --help\n```\n\n**Acceptance:**\n\n* The pilot has a dry-run dispatch plan.\n* Existing runtime visibility commands are used or their reuse break is documented.\n* Live dispatch remains off.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Dry-run launch planning with runtime visibility through existing `agent-pool-kick` and `agent-processes`.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/dry-run-dispatch-plan.md", + "/runtime-visibility-transcript.txt", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 7 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-008-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 9, + "call_label": "CALL 09", + "part": 1, + "title": "Reuse-Break Capture for First Pilot Spine", + "status": "PENDING", + "prompt": "## CALL 09: Reuse-Break Capture for First Pilot Spine\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nPrecise reuse-break capture across Factory, Build, Workset, Agent Work, and Parallel Delivery.\n\n**Operator-visible outcome:**\nThe operator gets a ranked list of real reuse breaks discovered during the first pilot spine.\n\n**Scaling/safety/reuse bottleneck addressed:**\nTurns failure into adapter backlog instead of prompting another generic foundation phase.\n\n**Why this is not orchestration theater:**\nThis call consolidates actual failed command/artifact handoffs from Calls 05–08 and converts them into bounded adapter tasks.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : Factory run directory, if created.\n* : dispatch preview from CALL 08.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nReuse existing evidence and failure reports. Do not create a separate defect tracker.\n\n**Codex must:**\n\n* Collect reuse breaks from Calls 05–08.\n* Normalize each break into owner surface, attempted command, expected artifact, actual failure, smallest adapter.\n* Rank breaks by whether they block the pilot.\n* Recommend the next concrete call to resolve each blocker.\n\n**Codex must not:**\n\n* Hide failed commands.\n* Rename failures as “future work” without adapter detail.\n* Create a new backlog system.\n* Duplicate existing Agent Work lifecycle tracking.\n\n**Existing surface that should own the work:**\n`agent-work` for lifecycle/governance; `parallel-delivery` for operator-facing summary.\n\n**Implementation / adapter allowance:**\nNo implementation unless a tiny parser is needed to read existing reuse-break reports.\n\n**Evidence to write:**\n`/pilot-reuse-break-backlog.md`\n`/pilot-reuse-breaks.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /pilot-reuse-break-backlog.md\ntest -s /pilot-reuse-breaks.json\npython3 -m json.tool /pilot-reuse-breaks.json >/dev/null\n./cento.sh agent-work --help\n```\n\n**Acceptance:**\n\n* Every blocker has an owner surface and smallest adapter.\n* Non-blocking breaks are separated from pilot blockers.\n* The next call recommendation is concrete.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Precise reuse-break capture across Factory, Build, Workset, Agent Work, and Parallel Delivery.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/pilot-reuse-break-backlog.md", + "/pilot-reuse-breaks.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 8 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-009-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 10, + "call_label": "CALL 10", + "part": 1, + "title": "First Pilot Spine State Receipt", + "status": "PENDING", + "prompt": "## CALL 10: First Pilot Spine State Receipt\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nPilot run state receipt for the Factory-backed dry-run spine.\n\n**Operator-visible outcome:**\nThe operator has one receipt saying exactly how far the first real pilot got and what artifact continues into Build/Workset/Agent Work.\n\n**Scaling/safety/reuse bottleneck addressed:**\nProvides a stable handoff from Factory dry-run work into patch unit, lease, lifecycle, and validation phases.\n\n**Why this is not orchestration theater:**\nThis is a real state receipt based on attempted commands and generated artifacts, not a plan summary.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : Factory run directory, if created.\n* : Workset queue plan, if created.\n* : dispatch preview, if created.\n* : consolidated pilot state receipt.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing Factory and Parallel Delivery receipt/evidence conventions before writing any new receipt shape.\n\n**Codex must:**\n\n* Summarize the current pilot state from real artifacts.\n* Identify the next artifact Build should consume.\n* Identify the next artifact Workset should consume.\n* Identify the next artifact Agent Work should consume.\n* Mark whether the pilot produced a real patch opportunity or is blocked.\n\n**Codex must not:**\n\n* Invent successful completion.\n* Create a new release candidate.\n* Create new lifecycle state.\n* Hide unresolved placeholders.\n\n**Existing surface that should own the work:**\n`factory` for run state, `parallel-delivery` for operator-facing status.\n\n**Implementation / adapter allowance:**\nOnly a read-only receipt normalizer if existing receipts are fragmented.\n\n**Evidence to write:**\n`/first-pilot-state-receipt.md`\n`/next-artifact-handoffs.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /first-pilot-state-receipt.md\ntest -s /next-artifact-handoffs.json\npython3 -m json.tool /next-artifact-handoffs.json >/dev/null\n./cento.sh parallel-delivery --help\n./cento.sh factory --help\n```\n\n**Acceptance:**\n\n* One pilot state receipt exists.\n* It names the next Build, Workset, and Agent Work handoffs.\n* It either identifies a real patch opportunity or names the blocker.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Pilot run state receipt for the Factory-backed dry-run spine.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/first-pilot-state-receipt.md", + "/next-artifact-handoffs.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 9 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-010-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 11, + "call_label": "CALL 11", + "part": 1, + "title": "Build Patch Bundle Canonicalization Probe", + "status": "PENDING", + "prompt": "## CALL 11: Build Patch Bundle Canonicalization Probe\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nBuild patch bundle canonicalization for the first pilot patch opportunity.\n\n**Operator-visible outcome:**\nThe operator can see whether the pilot can become a Build-owned patch unit.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents Patch Swarm from inventing a separate patch format.\n\n**Why this is not orchestration theater:**\nThis call hands the pilot’s real patch opportunity to Build or captures the exact incompatibility.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : pilot state receipt from CALL 10.\n* : Build-owned patch bundle candidate.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nBuild owns patch units and safety receipts. Use existing Build patch bundle behavior first.\n\n**Codex must:**\n\n* Resolve the pilot patch opportunity from ``.\n* Attempt to represent it as a Build patch bundle.\n* Validate whether Build can canonicalize or inspect the bundle.\n* Record exact command, input, and output.\n* Stop if the pilot has no patch opportunity and write a reuse-break/blocker report.\n\n**Codex must not:**\n\n* Create a Patch Swarm patch format.\n* Use raw patch as the normal path if Build supports bundle canonicalization.\n* Skip Build safety receipts.\n* Apply changes without dry-run validation.\n\n**Existing surface that should own the work:**\n`build`.\n\n**Implementation / adapter allowance:**\nOnly a minimal adapter that converts pilot output into an existing Build patch bundle format.\n\n**Evidence to write:**\n`/build-patch-bundle-result.md`\n`/build-command-transcript.txt`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /build-patch-bundle-result.md\ntest -s /build-command-transcript.txt\n./cento.sh build --help\n```\n\n**Acceptance:**\n\n* A Build-owned patch bundle is created/validated, or a precise reuse break is written.\n* Raw patch is not treated as the default happy path.\n* The next call can reason about owned paths and collision risk.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Build patch bundle canonicalization for the first pilot patch opportunity.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/build-patch-bundle-result.md", + "/build-command-transcript.txt", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 10 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-011-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 12, + "call_label": "CALL 12", + "part": 1, + "title": "Owned-Path Collision Prevention Probe", + "status": "PENDING", + "prompt": "## CALL 12: Owned-Path Collision Prevention Probe\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nOwned-path collision prevention for the pilot patch unit.\n\n**Operator-visible outcome:**\nThe operator can see whether the pilot patch conflicts with existing owned paths before integration.\n\n**Scaling/safety/reuse bottleneck addressed:**\nReduces unsafe parallel edits and hidden path collisions.\n\n**Why this is not orchestration theater:**\nThis call uses the actual patch bundle candidate and existing Workset/Build ownership semantics.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nWorkset owns leases and owned-path collision prevention. Build owns patch metadata. Use both before adding facade logic.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`workset` and `build`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/owned-path-collision-report.md`\n`/owned-paths.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /owned-path-collision-report.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Owned-path collision prevention for the pilot patch unit.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/owned-path-collision-report.md", + "/owned-paths.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 11 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-012-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 13, + "call_label": "CALL 13", + "part": 1, + "title": "Workset Lease Compatibility for Pilot Patch", + "status": "PENDING", + "prompt": "## CALL 13: Workset Lease Compatibility for Pilot Patch\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nWorkset lease compatibility and lease introspection for the pilot patch.\n\n**Operator-visible outcome:**\nThe operator can inspect whether the pilot patch has valid Workset lease coverage.\n\n**Scaling/safety/reuse bottleneck addressed:**\nMakes parallel work safe by checking lease ownership before validation/integration.\n\n**Why this is not orchestration theater:**\nThis call attempts to use the existing Workset lease substrate on the real pilot patch.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nWorkset owns lease creation, inspection, stale lease handling, and collision semantics.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`workset`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/workset-lease-compatibility.md`\n`/workset-lease-receipt.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /workset-lease-compatibility.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Workset lease compatibility and lease introspection for the pilot patch.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/workset-lease-compatibility.md", + "/workset-lease-receipt.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 12 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-013-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 14, + "call_label": "CALL 14", + "part": 1, + "title": "Agent Work Manifest Reuse for Pilot Lifecycle", + "status": "PENDING", + "prompt": "## CALL 14: Agent Work Manifest Reuse for Pilot Lifecycle\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nAgent Work manifest reuse for task lifecycle and governance.\n\n**Operator-visible outcome:**\nThe operator can track the pilot patch as lifecycle-governed work instead of a loose patch artifact.\n\n**Scaling/safety/reuse bottleneck addressed:**\nConnects Factory/Build/Workset execution to governance and lifecycle status.\n\n**Why this is not orchestration theater:**\nThis call tests whether existing Agent Work can consume the real pilot work unit or patch bundle.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nAgent Work owns lifecycle/governance. Use existing Agent Work manifests or commands before writing any new lifecycle artifact.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`agent-work`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/agent-work-manifest-reuse.md`\n`/agent-work-handoff.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /agent-work-manifest-reuse.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Agent Work manifest reuse for task lifecycle and governance.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/agent-work-manifest-reuse.md", + "/agent-work-handoff.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 13 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-014-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 15, + "call_label": "CALL 15", + "part": 1, + "title": "Validation Preflight for Pilot Patch", + "status": "PENDING", + "prompt": "## CALL 15: Validation Preflight for Pilot Patch\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nValidator replayability and validation preflight for the pilot patch.\n\n**Operator-visible outcome:**\nThe operator can know which validation commands must pass before dry-run integration.\n\n**Scaling/safety/reuse bottleneck addressed:**\nAvoids integrating patches with unknown or non-replayable validation.\n\n**Why this is not orchestration theater:**\nThis call uses the real pilot patch and existing validation commands/artifacts, not a new checklist.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse validation commands already represented in Build, Factory, Agent Work, or repo config before inventing new tests.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`build` and `agent-work`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/validation-preflight-receipt.md`\n`/validation-command-transcript.txt`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /validation-preflight-receipt.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Validator replayability and validation preflight for the pilot patch.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/validation-preflight-receipt.md", + "/validation-command-transcript.txt", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 14 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-015-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 16, + "call_label": "CALL 16", + "part": 1, + "title": "Parallel Delivery Status Bridge to Factory Pilot", + "status": "PENDING", + "prompt": "## CALL 16: Parallel Delivery Status Bridge to Factory Pilot\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nFactory-to-Parallel-Delivery status bridging.\n\n**Operator-visible outcome:**\nThe operator can ask Parallel Delivery for pilot status and see Factory-backed state instead of a duplicate state model.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents `parallel-delivery` from becoming a shadow Factory.\n\n**Why this is not orchestration theater:**\nThis call implements or validates the smallest facade adapter over real Factory state.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nParallel Delivery may display/adapt Factory state but may not own duplicate orchestration state.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`parallel-delivery` as facade; `factory` as status source.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/parallel-delivery-status-bridge.md`\n`/status-bridge-diff-summary.md`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /parallel-delivery-status-bridge.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Factory-to-Parallel-Delivery status bridging.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/parallel-delivery-status-bridge.md", + "/status-bridge-diff-summary.md", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 15 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-016-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 17, + "call_label": "CALL 17", + "part": 1, + "title": "Parallel Delivery Owner Routing Enforcement", + "status": "PENDING", + "prompt": "## CALL 17: Parallel Delivery Owner Routing Enforcement\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nFacade routing enforcement so Parallel Delivery delegates to Factory/Build/Workset/Agent Work.\n\n**Operator-visible outcome:**\nThe operator sees status/actions routed to the correct owning surface instead of mixed responsibility.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents long-term architecture rot from duplicate commands and shadow ownership.\n\n**Why this is not orchestration theater:**\nThis call hardens the real facade path introduced or validated in CALL 16.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse the responsibility owner map from existing evidence and registered commands. Parallel Delivery may route, not own.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`parallel-delivery` for routing; owner surfaces for actual behavior.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/owner-routing-enforcement.md`\n`/owner-routing-map.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /owner-routing-enforcement.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Facade routing enforcement so Parallel Delivery delegates to Factory/Build/Workset/Agent Work.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/owner-routing-enforcement.md", + "/owner-routing-map.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 16 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-017-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 18, + "call_label": "CALL 18", + "part": 1, + "title": "Release-Candidate Provenance Bridge", + "status": "PENDING", + "prompt": "## CALL 18: Release-Candidate Provenance Bridge\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nRelease-candidate provenance chains from Factory run → Build patch bundle → validation → release candidate.\n\n**Operator-visible outcome:**\nThe operator can trace a release candidate back to the pilot run and patch evidence.\n\n**Scaling/safety/reuse bottleneck addressed:**\nImproves rollback confidence and prevents release evidence from becoming disconnected.\n\n**Why this is not orchestration theater:**\nThis call links real artifacts from the pilot chain and exposes provenance through existing surfaces.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing release candidate and Build receipt conventions. Do not create a new release-candidate format.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`build` and `parallel-delivery`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/release-provenance-chain.md`\n`/provenance-chain.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /release-provenance-chain.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Release-candidate provenance chains from Factory run → Build patch bundle → validation → release candidate.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/release-provenance-chain.md", + "/provenance-chain.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 17 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-018-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 19, + "call_label": "CALL 19", + "part": 1, + "title": "Next-Action Surfacing from Real Pilot State", + "status": "PENDING", + "prompt": "## CALL 19: Next-Action Surfacing from Real Pilot State\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nNext-action surfacing for blocked or ready pilot states.\n\n**Operator-visible outcome:**\nThe operator can see the next safe action: validate, dry-run integrate, resolve reuse break, or stop.\n\n**Scaling/safety/reuse bottleneck addressed:**\nReduces human decision overhead and prevents ambiguous pilot state.\n\n**Why this is not orchestration theater:**\nNext actions are derived from real Factory/Build/Workset/Agent Work artifacts, not generic advice.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nParallel Delivery may surface next actions but must derive them from owner-surface artifacts.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`parallel-delivery` as facade; owner surfaces as action sources.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/next-action-surfacing.md`\n`/next-action-receipt.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /next-action-surfacing.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Next-action surfacing for blocked or ready pilot states.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/next-action-surfacing.md", + "/next-action-receipt.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 18 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-019-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 20, + "call_label": "CALL 20", + "part": 1, + "title": "Evidence Lineage Bridge in Parallel Delivery Facade", + "status": "PENDING", + "prompt": "## CALL 20: Evidence Lineage Bridge in Parallel Delivery Facade\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nEvidence lineage surfaced through Parallel Delivery without owning evidence storage.\n\n**Operator-visible outcome:**\nThe operator can see pilot evidence lineage from the facade and jump to owner artifacts.\n\n**Scaling/safety/reuse bottleneck addressed:**\nMakes evidence reusable across runs without creating a second evidence hub.\n\n**Why this is not orchestration theater:**\nThis uses the pilot’s real evidence chain and facade-only display/routing.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nEvidence remains where owner surfaces write it. Parallel Delivery only adapts, displays, and links.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`parallel-delivery` as facade; owner surfaces as evidence writers.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/parallel-delivery-evidence-lineage.md`\n`/facade-lineage-diff-summary.md`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /parallel-delivery-evidence-lineage.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Evidence lineage surfaced through Parallel Delivery without owning evidence storage.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/parallel-delivery-evidence-lineage.md", + "/facade-lineage-diff-summary.md", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 19 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-020-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 21, + "call_label": "CALL 21", + "part": 1, + "title": "Validator Replay Receipt", + "status": "PENDING", + "prompt": "## CALL 21: Validator Replay Receipt\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nValidator replayability for the pilot patch.\n\n**Operator-visible outcome:**\nThe operator can replay validation commands for the pilot and compare results.\n\n**Scaling/safety/reuse bottleneck addressed:**\nMakes validation deterministic enough for future parallel patch integration.\n\n**Why this is not orchestration theater:**\nThis call runs or records actual validation commands against the pilot state.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse Build/Agent Work validation commands before adding new validation behavior.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`build` and `agent-work`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/validator-replay-receipt.md`\n`/validator-replay-transcript.txt`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /validator-replay-receipt.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Validator replayability for the pilot patch.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/validator-replay-receipt.md", + "/validator-replay-transcript.txt", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 20 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-021-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 22, + "call_label": "CALL 22", + "part": 1, + "title": "Build Integration Dry-Run Attempt", + "status": "PENDING", + "prompt": "## CALL 22: Build Integration Dry-Run Attempt\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nDeterministic patch integration receipts through Build dry-run.\n\n**Operator-visible outcome:**\nThe operator can see whether the pilot patch would integrate cleanly without applying unsafe changes.\n\n**Scaling/safety/reuse bottleneck addressed:**\nMakes patch integration auditable before merge/apply.\n\n**Why this is not orchestration theater:**\nThis call attempts a real Build integration dry-run against the pilot patch bundle.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nBuild owns patch integration dry-run and safety receipts.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`build`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/integration-dry-run-receipt.md`\n`/integration-dry-run-transcript.txt`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /integration-dry-run-receipt.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Deterministic patch integration receipts through Build dry-run.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/integration-dry-run-receipt.md", + "/integration-dry-run-transcript.txt", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 21 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-022-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 23, + "call_label": "CALL 23", + "part": 1, + "title": "Integration Conflict Surfacing", + "status": "PENDING", + "prompt": "## CALL 23: Integration Conflict Surfacing\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nIntegration conflict surfacing for operator review.\n\n**Operator-visible outcome:**\nThe operator can see whether conflicts exist, which files are affected, and which surface should resolve them.\n\n**Scaling/safety/reuse bottleneck addressed:**\nAvoids silent dry-run failures and makes integration blockers actionable.\n\n**Why this is not orchestration theater:**\nThis call reads the actual integration dry-run receipt and surfaces concrete conflicts or clean status.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nBuild owns integration conflict data. Parallel Delivery may display it read-only.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`build`, with `parallel-delivery` as optional facade.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/integration-conflict-surfacing.md`\n`/conflict-status.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /integration-conflict-surfacing.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Integration conflict surfacing for operator review.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/integration-conflict-surfacing.md", + "/conflict-status.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 22 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-023-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 24, + "call_label": "CALL 24", + "part": 1, + "title": "Release Candidate Receipt Attempt", + "status": "PENDING", + "prompt": "## CALL 24: Release Candidate Receipt Attempt\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nRelease-candidate receipt generation from validated dry-run integration.\n\n**Operator-visible outcome:**\nThe operator can see whether the pilot is release-candidate-ready or exactly why it is blocked.\n\n**Scaling/safety/reuse bottleneck addressed:**\nConnects validation and integration evidence into a shippable readiness signal.\n\n**Why this is not orchestration theater:**\nThis call attempts to produce a release candidate receipt from real pilot evidence or writes the blocker.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing release-candidate receipt conventions. Do not create a new release format for Patch Swarm.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`build` and `parallel-delivery`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/pilot-release-candidate-result.md`\n`/pilot-release-candidate-receipt.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /pilot-release-candidate-result.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Release-candidate receipt generation from validated dry-run integration.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/pilot-release-candidate-result.md", + "/pilot-release-candidate-receipt.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 23 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-024-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 25, + "call_label": "CALL 25", + "part": 1, + "title": "Demo Evidence Reproducibility Hub", + "status": "PENDING", + "prompt": "## CALL 25: Demo Evidence Reproducibility Hub\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nDemo evidence reproducibility from the first Factory-backed pilot.\n\n**Operator-visible outcome:**\nThe operator can demo the pilot outcome with links to real evidence, commands, receipts, and blockers.\n\n**Scaling/safety/reuse bottleneck addressed:**\nTurns pilot execution into reusable demo material without manually stitching screenshots and notes.\n\n**Why this is not orchestration theater:**\nThe demo evidence is generated from actual pilot artifacts and receipts, not a mock showcase.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse `demo-evidence` and `temp` utilities before creating any new demo hub.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`demo-evidence` and `temp`, with `parallel-delivery` as facade if supported.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/demo-evidence-reproducibility.md`\n`/demo-hub-receipt.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /demo-evidence-reproducibility.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Demo evidence reproducibility from the first Factory-backed pilot.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/demo-evidence-reproducibility.md", + "/demo-hub-receipt.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 24 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-025-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 26, + "call_label": "CALL 26", + "part": 1, + "title": "Second Tiny Pilot Selection or Blocker Continuation", + "status": "PENDING", + "prompt": "## CALL 26: Second Tiny Pilot Selection or Blocker Continuation\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nRepeatable command path or highest-priority reuse-break closure.\n\n**Operator-visible outcome:**\nThe operator either gets a second tiny pilot to prove repeatability or a focused continuation that resolves the top blocker from the first pilot.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents one-off success and turns pilot learning into repeatable system behavior.\n\n**Why this is not orchestration theater:**\nThis call chooses based on real first-pilot evidence: repeat if successful, repair if blocked.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse the first pilot’s actual outcome. Do not start a second pilot if the first pilot exposed a blocking adapter gap that prevents reuse.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\nDepends on outcome: `factory` for second pilot; relevant owner surface for blocker continuation.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/second-pilot-or-blocker-decision.md`\n`/second-pilot-or-blocker-spec.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /second-pilot-or-blocker-decision.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Repeatable command path or highest-priority reuse-break closure.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/second-pilot-or-blocker-decision.md", + "/second-pilot-or-blocker-spec.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 25 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-026-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 27, + "call_label": "CALL 27", + "part": 1, + "title": "Adapter Validation or Second Pilot Factory Reuse", + "status": "PENDING", + "prompt": "## CALL 27: Adapter Validation or Second Pilot Factory Reuse\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nRepeatable command path validation or adapter validation for the top reuse break.\n\n**Operator-visible outcome:**\nThe operator sees whether the system can repeat the first pilot path or whether the selected adapter closes the blocker.\n\n**Scaling/safety/reuse bottleneck addressed:**\nConverts learning into durable execution capability.\n\n**Why this is not orchestration theater:**\nThis call executes the selected continuation from CALL 26 against real repo state.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nRun the same existing command path where possible. If resolving a blocker, patch only the smallest adapter.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\nThe owner surface identified in CALL 26.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/adapter-or-repeatability-validation.md`\n`/adapter-validation-transcript.txt`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /adapter-or-repeatability-validation.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Repeatable command path validation or adapter validation for the top reuse break.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/adapter-or-repeatability-validation.md", + "/adapter-validation-transcript.txt", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 26 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-027-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 28, + "call_label": "CALL 28", + "part": 1, + "title": "Stale Lease Recovery and Queue Starvation Probe", + "status": "PENDING", + "prompt": "## CALL 28: Stale Lease Recovery and Queue Starvation Probe\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nStale lease recovery and queue starvation prevention for pilot work.\n\n**Operator-visible outcome:**\nThe operator can tell whether queued/leased pilot work can get stuck and what existing command recovers it.\n\n**Scaling/safety/reuse bottleneck addressed:**\nImproves reliability of future parallel runs where workers may fail, stall, or abandon leases.\n\n**Why this is not orchestration theater:**\nThis call uses the real pilot/second-pilot lease and queue artifacts or records why they do not exist.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nWorkset owns leases and recovery. Factory owns queued work. Do not add recovery behavior under Parallel Delivery.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`workset` and `factory`.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/lease-recovery-and-starvation-probe.md`\n`/lease-recovery-receipt.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /lease-recovery-and-starvation-probe.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Stale lease recovery and queue starvation prevention for pilot work.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/lease-recovery-and-starvation-probe.md", + "/lease-recovery-receipt.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 27 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-028-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 29, + "call_label": "CALL 29", + "part": 1, + "title": "Pilot-to-Pilot Learning Receipt", + "status": "PENDING", + "prompt": "## CALL 29: Pilot-to-Pilot Learning Receipt\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nPilot-to-pilot learning and repeatable command path documentation.\n\n**Operator-visible outcome:**\nThe operator gets a compact receipt showing what became repeatable, what still breaks, and what adapter improved the system.\n\n**Scaling/safety/reuse bottleneck addressed:**\nTurns two pilot attempts or one pilot plus blocker fix into reusable operational knowledge.\n\n**Why this is not orchestration theater:**\nThis call compares actual artifacts from the first pilot and continuation path.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing closeout/evidence conventions. Do not create a new learning database or dashboard.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`parallel-delivery` for operator-facing learning summary, owner surfaces for underlying receipts.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/pilot-to-pilot-learning.md`\n`/pilot-learning-receipt.json`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /pilot-to-pilot-learning.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Pilot-to-pilot learning and repeatable command path documentation.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/pilot-to-pilot-learning.md", + "/pilot-learning-receipt.json", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 28 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-029-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 30, + "call_label": "CALL 30", + "part": 1, + "title": "Part 1 Closeout and Adapter Backlog", + "status": "PENDING", + "prompt": "## CALL 30: Part 1 Closeout and Adapter Backlog\n\n**Your task:**\nYour task is to generate/execute this as a Codex-local implementation packet inside the operator’s repo.\nDo not assume ChatGPT will run commands, inspect files, or write evidence.\n\n**System capability improved:**\nPart 1 closeout for real pilot progress, adapter backlog, and readiness input for CALLS 31–60.\n\n**Operator-visible outcome:**\nThe operator gets a grounded postmortem: what shipped, what reused existing surfaces, what broke, what adapter work comes next.\n\n**Scaling/safety/reuse bottleneck addressed:**\nPrevents the next 30 calls from drifting into generic planning by anchoring them to pilot evidence.\n\n**Why this is not orchestration theater:**\nThe closeout is based on real pilot execution, patches, receipts, dry-runs, validation, and blockers.\n\n**Placeholder bindings:**\n\n* : operator’s current Cento repo root.\n* : evidence directory for this call.\n* : previous run directory this call continues from or resolves from current repo state.\n* : prior evidence/artifacts resolved from current repo state.\n* : latest valid Patch Swarm closeout summary.\n* : latest valid final QA summary.\n* : latest valid release candidate receipt.\n* : latest valid demo evidence receipt.\n* : latest responsibility audit directory.\n* : registry/index of generated Pro/Codex calls, if present.\n* : report written when placeholders/artifacts cannot be resolved.\n\n**Placeholder resolution rule:**\nResolve these placeholders from the current repo state before acting. If a required placeholder cannot be resolved, write `` under `` and stop instead of inventing a substitute.\n\n**Reuse-first rule:**\nUse existing closeout, demo evidence, and Parallel Delivery reporting conventions. Do not create a new product surface.\n\n**Codex must:**\n\n* Resolve required placeholders from current repo state.\n* Use the existing owner surface before adding adapters.\n* Write the named evidence files.\n* Record precise blockers and reuse breaks.\n\n**Codex must not:**\n\n* Invent a duplicate primitive.\n* Hide failed commands or unresolved placeholders.\n* Create a new fixture/schema/ledger/queue/inbox/dashboard/release format.\n* Launch live workers or call live Pro/API unless explicitly opted in.\n\n**Existing surface that should own the work:**\n`parallel-delivery`, `demo-evidence`, and owner-surface receipts.\n\n**Implementation / adapter allowance:**\nOnly the smallest adapter over existing artifacts/surfaces is allowed when a real reuse break is proven.\n\n**Evidence to write:**\n`/part1-closeout-summary.md`\n`/adapter-backlog.json`\n`/calls31-60-readiness-input.md`\n`/reuse-gate.md`\n\n**Validation:**\n\n```bash\ncd \ntest -s /part1-closeout-summary.md\ntest -s /reuse-gate.md\n./cento.sh parallel-delivery --help\n```\n\n**Acceptance:**\n\n* The call produces the named evidence or a precise reuse break.\n* Owner surfaces remain source of truth.\n* No duplicate primitive is introduced.\n\n**Failure behavior:**\nIf the existing surface cannot consume the expected artifact, do not invent a new surface. Write `` explaining:\n\n* attempted command\n* expected input\n* actual failure\n* existing owner surface\n* smallest adapter needed\n* recommended next call\n\n**Reuse Gate:**\nBefore editing, Codex must answer:\n\n1. What existing command already does this?\n2. What existing artifact already represents this?\n3. What existing evidence proves this worked before?\n4. What existing tool should consume this next?\n5. Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?\n6. Is any new fixture replacing real execution?\n7. What is the smallest adapter needed?\n8. What user-visible outcome happens after reuse?\n\nIf these cannot be answered, stop and write ``.\n", + "placeholder": false, + "summary": "Part 1 closeout for real pilot progress, adapter backlog, and readiness input for CALLS 31–60.", + "previous_run_directory": "", + "existing_artifacts": [ + "" + ], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [ + "/part1-closeout-summary.md", + "/adapter-backlog.json", + "/calls31-60-readiness-input.md", + "/reuse-gate.md" + ], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 29 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [ + "workspace/runs/parallel-delivery/pro-call-registry/calls-00-30-new-ingest-20260513T234500Z/call-030-prompt.md" + ], + "events": [ + { + "timestamp": "2026-05-13T23:41:09Z", + "actor": "codex", + "event": "prompt_ingested_from_operator_message", + "next_placeholder": false + } + ], + "notes": "Prompt populated from operator-supplied new call-spec contract. Pro_output intentionally empty." + }, + { + "call_id": 31, + "call_label": "CALL 31", + "part": 2, + "title": "Awaiting CALL 31 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 30 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 32, + "call_label": "CALL 32", + "part": 2, + "title": "Awaiting CALL 32 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 31 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 33, + "call_label": "CALL 33", + "part": 2, + "title": "Awaiting CALL 33 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 32 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 34, + "call_label": "CALL 34", + "part": 2, + "title": "Awaiting CALL 34 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 33 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 35, + "call_label": "CALL 35", + "part": 2, + "title": "Awaiting CALL 35 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 34 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 36, + "call_label": "CALL 36", + "part": 2, + "title": "Awaiting CALL 36 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 35 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 37, + "call_label": "CALL 37", + "part": 2, + "title": "Awaiting CALL 37 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 36 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 38, + "call_label": "CALL 38", + "part": 2, + "title": "Awaiting CALL 38 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 37 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 39, + "call_label": "CALL 39", + "part": 2, + "title": "Awaiting CALL 39 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 38 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 40, + "call_label": "CALL 40", + "part": 2, + "title": "Awaiting CALL 40 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 39 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 41, + "call_label": "CALL 41", + "part": 2, + "title": "Awaiting CALL 41 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 40 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 42, + "call_label": "CALL 42", + "part": 2, + "title": "Awaiting CALL 42 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 41 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 43, + "call_label": "CALL 43", + "part": 2, + "title": "Awaiting CALL 43 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 42 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 44, + "call_label": "CALL 44", + "part": 2, + "title": "Awaiting CALL 44 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 43 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 45, + "call_label": "CALL 45", + "part": 2, + "title": "Awaiting CALL 45 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 44 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 46, + "call_label": "CALL 46", + "part": 2, + "title": "Awaiting CALL 46 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 45 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 47, + "call_label": "CALL 47", + "part": 2, + "title": "Awaiting CALL 47 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 46 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 48, + "call_label": "CALL 48", + "part": 2, + "title": "Awaiting CALL 48 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 47 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 49, + "call_label": "CALL 49", + "part": 2, + "title": "Awaiting CALL 49 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 48 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 50, + "call_label": "CALL 50", + "part": 2, + "title": "Awaiting CALL 50 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 49 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 51, + "call_label": "CALL 51", + "part": 2, + "title": "Awaiting CALL 51 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 50 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 52, + "call_label": "CALL 52", + "part": 2, + "title": "Awaiting CALL 52 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 51 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 53, + "call_label": "CALL 53", + "part": 2, + "title": "Awaiting CALL 53 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 52 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 54, + "call_label": "CALL 54", + "part": 2, + "title": "Awaiting CALL 54 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 53 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 55, + "call_label": "CALL 55", + "part": 2, + "title": "Awaiting CALL 55 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 54 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 56, + "call_label": "CALL 56", + "part": 2, + "title": "Awaiting CALL 56 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 55 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 57, + "call_label": "CALL 57", + "part": 2, + "title": "Awaiting CALL 57 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 56 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 58, + "call_label": "CALL 58", + "part": 2, + "title": "Awaiting CALL 58 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 57 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 59, + "call_label": "CALL 59", + "part": 2, + "title": "Awaiting CALL 59 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 58 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 60, + "call_label": "CALL 60", + "part": 2, + "title": "Awaiting CALL 60 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 59 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 61, + "call_label": "CALL 61", + "part": 3, + "title": "Awaiting CALL 61 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 60 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 62, + "call_label": "CALL 62", + "part": 3, + "title": "Awaiting CALL 62 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 61 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 63, + "call_label": "CALL 63", + "part": 3, + "title": "Awaiting CALL 63 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 62 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 64, + "call_label": "CALL 64", + "part": 3, + "title": "Awaiting CALL 64 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 63 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 65, + "call_label": "CALL 65", + "part": 3, + "title": "Awaiting CALL 65 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 64 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 66, + "call_label": "CALL 66", + "part": 3, + "title": "Awaiting CALL 66 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 65 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 67, + "call_label": "CALL 67", + "part": 3, + "title": "Awaiting CALL 67 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 66 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 68, + "call_label": "CALL 68", + "part": 3, + "title": "Awaiting CALL 68 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 67 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 69, + "call_label": "CALL 69", + "part": 3, + "title": "Awaiting CALL 69 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 68 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 70, + "call_label": "CALL 70", + "part": 3, + "title": "Awaiting CALL 70 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 69 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 71, + "call_label": "CALL 71", + "part": 3, + "title": "Awaiting CALL 71 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 70 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 72, + "call_label": "CALL 72", + "part": 3, + "title": "Awaiting CALL 72 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 71 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 73, + "call_label": "CALL 73", + "part": 3, + "title": "Awaiting CALL 73 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 72 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 74, + "call_label": "CALL 74", + "part": 3, + "title": "Awaiting CALL 74 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 73 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 75, + "call_label": "CALL 75", + "part": 3, + "title": "Awaiting CALL 75 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 74 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 76, + "call_label": "CALL 76", + "part": 3, + "title": "Awaiting CALL 76 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 75 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 77, + "call_label": "CALL 77", + "part": 3, + "title": "Awaiting CALL 77 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 76 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 78, + "call_label": "CALL 78", + "part": 3, + "title": "Awaiting CALL 78 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 77 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 79, + "call_label": "CALL 79", + "part": 3, + "title": "Awaiting CALL 79 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 78 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 80, + "call_label": "CALL 80", + "part": 3, + "title": "Awaiting CALL 80 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 79 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 81, + "call_label": "CALL 81", + "part": 3, + "title": "Awaiting CALL 81 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 80 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 82, + "call_label": "CALL 82", + "part": 3, + "title": "Awaiting CALL 82 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 81 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 83, + "call_label": "CALL 83", + "part": 3, + "title": "Awaiting CALL 83 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 82 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 84, + "call_label": "CALL 84", + "part": 3, + "title": "Awaiting CALL 84 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 83 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 85, + "call_label": "CALL 85", + "part": 3, + "title": "Awaiting CALL 85 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 84 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 86, + "call_label": "CALL 86", + "part": 3, + "title": "Awaiting CALL 86 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 85 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 87, + "call_label": "CALL 87", + "part": 3, + "title": "Awaiting CALL 87 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 86 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 88, + "call_label": "CALL 88", + "part": 3, + "title": "Awaiting CALL 88 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 87 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 89, + "call_label": "CALL 89", + "part": 3, + "title": "Awaiting CALL 89 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 88 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 90, + "call_label": "CALL 90", + "part": 3, + "title": "Awaiting CALL 90 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 89 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 91, + "call_label": "CALL 91", + "part": 3, + "title": "Awaiting CALL 91 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 90 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 92, + "call_label": "CALL 92", + "part": 3, + "title": "Awaiting CALL 92 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 91 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 93, + "call_label": "CALL 93", + "part": 3, + "title": "Awaiting CALL 93 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 92 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 94, + "call_label": "CALL 94", + "part": 3, + "title": "Awaiting CALL 94 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 93 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 95, + "call_label": "CALL 95", + "part": 3, + "title": "Awaiting CALL 95 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 94 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 96, + "call_label": "CALL 96", + "part": 3, + "title": "Awaiting CALL 96 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 95 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 97, + "call_label": "CALL 97", + "part": 3, + "title": "Awaiting CALL 97 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 96 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 98, + "call_label": "CALL 98", + "part": 3, + "title": "Awaiting CALL 98 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 97 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 99, + "call_label": "CALL 99", + "part": 3, + "title": "Awaiting CALL 99 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 98 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + }, + { + "call_id": 100, + "call_label": "CALL 100", + "part": 3, + "title": "Awaiting CALL 100 prompt", + "status": "PENDING", + "prompt": "", + "placeholder": true, + "summary": "Clean placeholder awaiting operator-supplied prompt.", + "previous_run_directory": "", + "existing_artifacts": [], + "existing_commands": [], + "evidence_dir": "", + "required_evidence_files": [], + "reuse_gate_questions": [ + "What existing command already does this?", + "What existing artifact already represents this?", + "What existing evidence proves this worked before?", + "What existing tool should consume this next?", + "Why can’t Factory/Build/Workset/Parallel Delivery/Agent Work be reused directly?", + "Is any new fixture replacing real execution?", + "What is the smallest adapter needed?", + "What user-visible outcome happens after reuse?" + ], + "depends_on": [ + 99 + ], + "sequential_gate": true, + "Pro_output": "", + "pro_output_received_at": null, + "codex_evidence": [], + "events": [], + "notes": "Prompt intentionally empty after registry cleanup; populate with exact operator-supplied call prompt before copying to Pro." + } + ] +} diff --git a/data/tools.json b/data/tools.json index 561d565..8ba8ac4 100644 --- a/data/tools.json +++ b/data/tools.json @@ -28,6 +28,16 @@ "cento install zsh", "cento install tmux", "cento run scan --query \"mcp\"", + "cento build --help", + "cento build init --task \"Fixture docs page patch\" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture", + "cento build check tests/fixtures/cento_build/manifest.valid.json", + "cento runtime check codex-fast", + "cento workset check tests/fixtures/cento_workset/workset.valid.json", + "cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke", + "cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke", + "cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke", + "cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff", + "cento build integrate tests/fixtures/cento_build/manifest.valid.json --bundle .cento/builds/build_fixture_docs_page_001/integration/patch_bundle.json --dry-run", "cento platforms", "cento platforms macos", "cento platforms linux", @@ -170,12 +180,45 @@ }, { "name": "run", - "summary": "Run a registered tool by id.", - "usage": "cento run TOOL [args...]", + "summary": "Run a registered tool by id, or create a fast/standard/thorough execution contract with optional one-local-builder patch collection for owned-path tasks.", + "usage": "cento run TOOL [args...] | cento run fast|standard|thorough --task TEXT [--write PATH] [--local-builder [RUNTIME] --apply]", "flags": [], "examples": [ "cento run scan --query \"mcp\"", - "cento run crm docs" + "cento run crm docs", + "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo", + "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo --local-builder fixture --fixture-case valid --apply --validation smoke --commit none" + ] + }, + { + "name": "build", + "summary": "Own patch units: create manifest-owned local build packages, run one local worker, check artifacts, synthesize patch bundles, dry-run integrate, and apply accepted bundles.", + "usage": "cento build [args...]", + "flags": [], + "examples": [ + "cento build init --task \"Fixture docs page patch\" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture", + "cento build check tests/fixtures/cento_build/manifest.valid.json", + "cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180", + "cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime-profile codex-fast --worktree", + "cento runtime check codex-fast", + "cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke", + "cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff", + "cento build integrate .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --worktree --dry-run", + "cento build apply .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --from-receipt .cento/builds//integration_receipt.json" + ] + }, + { + "name": "workset", + "summary": "Own parallel lease semantics with local N-worker worksets, exclusive write paths, structured API artifacts, dependency gates, and sequential integration.", + "usage": "cento workset [args...]", + "flags": [], + "examples": [ + "cento workset check tests/fixtures/cento_workset/workset.valid.json", + "cento workset check tests/fixtures/cento_workset/workset.overlap.json", + "cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke", + "cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke", + "cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke", + "cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke" ] } ] @@ -306,7 +349,6 @@ "cento crm intake init --person \"Ada Lovelace\"", "cento crm intake add --person \"Ada Lovelace\" --kind resume --file ./resume.pdf", "cento crm intake plan --person \"Ada Lovelace\"", - "cento crm integration --provider redmine --person \"Ada Lovelace\" --start-workflow --dry-run", "cento crm serve --open", "cento crm show", "cento crm docs" @@ -319,14 +361,12 @@ "workspace/runs/career-intake//manifest.json", "workspace/runs/career-intake//artifact-plan.md", "workspace/runs/career-intake//prompts/*.md", - "workspace/runs/career-intake//artifacts/*.md", - "Project and issues via REST API" + "workspace/runs/career-intake//artifacts/*.md" ], "notes": [ "Run cento crm serve to host the local SPA through the cento CLI.", "Run cento crm init to bootstrap app state from the saved questionnaire.", "Run cento crm intake to collect raw candidate inputs and generate a Codex-ready artifact plan.", - "Run cento crm integration --provider redmine --start-workflow to create a workflow from generated artifacts.", "The CRM is a no-build local app backed by JSON persistence." ] }, @@ -394,6 +434,34 @@ "This tool follows standards/mcp.md." ] }, + { + "id": "cento-mcp", + "name": "Cento MCP Server", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/cento_mcp_server.py", + "description": "Local MCP stdio server that exposes safe Cento agent-work, story manifest, cluster, bridge, and context tools.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "python3 scripts/cento_mcp_server.py --list-tools", + "python3 scripts/cento_mcp_server.py --call-tool cento_agent_work_list --arguments '{}'", + "python3 scripts/cento_mcp_server.py --call-tool cento_context --arguments '{\"remote\":false}'", + "cento mcp doctor" + ], + "outputs": [ + "MCP tools over stdio", + "structured JSON command results" + ], + "notes": [ + "Configured as the `cento` server in .mcp.json.", + "The server exposes explicit write tools for agent-work mutations and story hub generation.", + "Set CENTO_MCP_READ_ONLY=1 to disable write tools.", + "Local paths are constrained to the Cento repo root." + ] + }, { "id": "scan", "name": "Scan One Pager", @@ -423,137 +491,6 @@ "See docs/scan-onepager.md for the command surface and output model." ] }, - { - "id": "validator-tier0", - "name": "Validator Tier 0", - "lane": "agent ops", - "kind": "python", - "entrypoint": "./scripts/validator_tier0.py", - "description": "Create validation packets and run deterministic Tier 0 checks with mandatory timing and AI budget stats.", - "platforms": [ - "linux", - "macos" - ], - "commands": [ - "cento validator-tier0 stories", - "cento validator-tier0 run workspace/runs/validator-tier0/e2e/sample-pass.json", - "cento validator-tier0 e2e", - "cento validator-tier0 run workspace/runs/agent-work/no-model-validation-e2e/validation.json --run-dir workspace/runs/agent-work/no-model-validation-e2e/tier0" - ], - "outputs": [ - "workspace/runs/validator-tier0/stories.json", - "workspace/runs/validator-tier0/*/validation-packet.json", - "workspace/runs/validator-tier0/*/validation-result.json", - "workspace/runs/validator-tier0/*/validation-summary.md", - "workspace/runs/validator-tier0/*/stats.json" - ], - "notes": [ - "Tier 0 uses deterministic checks only: file_exists, command, json_field, contains_text, http_status, and image_nonblank.", - "Every run records total_duration_ms, per-check duration_ms, manual_review_count, automation_coverage_percent, ai_calls_used, and estimated_ai_cost.", - "The e2e command creates one passing sample and one failing sample to prove the path end to end." - ] - }, - { - "id": "story-manifest", - "name": "Story Manifest", - "lane": "agent ops", - "kind": "python", - "entrypoint": "./scripts/story_manifest.py", - "description": "Validate, draft, and render Cento agent-work story.json manifests.", - "platforms": [ - "linux", - "macos" - ], - "commands": [ - "cento story-manifest draft --title \"Fix dashboard\" --package app --expected-output workspace/runs/agent-work/drafts/fix-dashboard/evidence.md", - "cento story-manifest validate workspace/runs/agent-work/no-model-validation-e2e/story.json", - "cento story-manifest render-hub workspace/runs/agent-work/1000086/story.json" - ], - "outputs": [ - "workspace/runs/agent-work/*/story.json", - "workspace/runs/agent-work/*/deliverables.json", - "workspace/runs/agent-work/*/start-here.html" - ], - "notes": [ - "Draft manifests use issue.id=0 before agent-work create canonicalizes the real issue id.", - "No-model drafts include explicit escalation triggers and deterministic validation inputs." - ] - }, - { - "id": "validation-manifest", - "name": "Validation Manifest", - "lane": "agent ops", - "kind": "python", - "entrypoint": "./scripts/validation_manifest.py", - "description": "Generate deterministic validation.json checks from story.json and enforce no-model coverage guardrails.", - "platforms": [ - "linux", - "macos" - ], - "commands": [ - "cento validation-manifest draft workspace/runs/agent-work/no-model-validation-e2e/story.json --output workspace/runs/agent-work/no-model-validation-e2e/validation.json", - "cento validation-manifest validate workspace/runs/agent-work/no-model-validation-e2e/validation.json" - ], - "outputs": [ - "workspace/runs/agent-work/*/validation.json" - ], - "notes": [ - "Only explicit artifacts, commands, text assertions, JSON fields, URLs, and screenshots become deterministic checks.", - "Unresolved manual_review items block preflight until accepted, covered, or waived." - ] - }, - { - "id": "no-model-validation-e2e", - "name": "No-model Validation E2E", - "lane": "agent ops", - "kind": "python", - "entrypoint": "./scripts/no_model_validation_e2e.py", - "description": "Run generated story manifest, generated validation manifest, agent-work preflight, and Tier 0 validation in one zero-AI evidence loop.", - "platforms": [ - "linux", - "macos" - ], - "commands": [ - "cento no-model-validation-e2e", - "cento no-model-validation-e2e --run-dir workspace/runs/agent-work/no-model-validation-e2e" - ], - "outputs": [ - "workspace/runs/agent-work/no-model-validation-e2e/story.json", - "workspace/runs/agent-work/no-model-validation-e2e/validation.json", - "workspace/runs/agent-work/no-model-validation-e2e/preflight.json", - "workspace/runs/agent-work/no-model-validation-e2e/tier0/validation-result.json", - "workspace/runs/agent-work/no-model-validation-e2e/e2e-summary.json" - ], - "notes": [ - "The run records command duration, total duration, automation coverage, manual review count, ai_calls_used, and estimated_ai_cost.", - "The target no-model path is 95%+ automatic; the current generated-fixture E2E is 100% automatic." - ] - }, - { - "id": "manifest-validate", - "name": "Manifest Validate", - "lane": "agent ops", - "kind": "python", - "entrypoint": "./scripts/manifest_validate.py", - "description": "Deterministically validate story.json and validation.json pairs, including evidence paths, API specs, and allowlisted commands without AI.", - "platforms": [ - "linux", - "macos" - ], - "commands": [ - "cento manifest-validate --story workspace/runs/agent-work/1000088/story.json --validation workspace/runs/agent-work/1000088/validation.json --json --report workspace/runs/agent-work/1000088/validation-report.md", - "python3 ./scripts/manifest_validate.py --story workspace/runs/agent-work/1000088/story.json --json" - ], - "outputs": [ - "workspace/runs/agent-work//validation-report.md", - "workspace/runs/agent-work//validation-report.json" - ], - "notes": [ - "Uses story.json and validation.json as deterministic inputs.", - "Command checks fail on non-zero exits and escalate when a command is not allowlisted.", - "The default report lives next to the validation manifest unless overridden with --report." - ] - }, { "id": "bluetooth-audio-doctor", "name": "Bluetooth Audio Doctor", @@ -661,12 +598,46 @@ "notes": [ "industrial-os writes a guarded block to ~/.config/i3/config so i3 reloads keep the preset active.", "The preset applies the Cento Industrial OS Kitty theme, generated wallpaper, Polybar config, Rofi theme, and Picom config; the themed dashboard server stays on the explicit --dashboard-only path.", - "Mod+Shift+I runs --workspace and composes workspace 1 into the Discord, hero, terminal, jobs, cluster, activity, and actions tile layout with background images on every generated pane without starting the dashboard server.", + "Mod+Shift+I runs --workspace and composes workspace 1 into the Discord, hero, terminal, Darth Lolipopus pet, cluster, activity, and actions tile layout with background images on every generated pane without starting the dashboard server.", "Use --black-only or CENTO_INDUSTRIAL_BACKGROUND_MODE=black for plain black workspace pane backgrounds.", "Mod+h/j/k/l uses the Industrial OS visual focus router on the cockpit and falls back to native i3 focus elsewhere.", "--session reapplies runtime pieces without rewriting the i3 config and is intended for i3 startup." ] }, + { + "id": "industrial-pet", + "name": "Darth Lolipopus Pet Pane", + "lane": "desktop ops", + "kind": "shell", + "entrypoint": "./scripts/industrial_pet_tui.sh", + "description": "Cute Sith Tamagotchi pane for Darth Lolipopus in the Industrial OS workspace.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento industrial-pet", + "cento industrial-pet --once --width 98 --height 24", + "cento industrial-pet --action nap", + "cento industrial-pet --image assets/industrial-os/darth-lolipopus.png", + "cento industrial-pet --portrait slot", + "cento industrial-pet --reset" + ], + "outputs": [ + "${XDG_STATE_HOME:-~/.local/state}/cento/industrial-os/darth-lolipopus.json", + "interactive terminal pet pane" + ], + "docs": [ + "docs/industrial-pet.md" + ], + "notes": [ + "State path, database path, and portrait image path are overrideable with --state, --database, and --image for deterministic tests.", + "Industrial OS launches this pane in the bottom-left tile that previously hosted the jobs dashboard.", + "The default portrait uses assets/industrial-os/darth-lolipopus.png, matching the rofi launcher side art.", + "Industrial OS uses assets/industrial-os/darth-lolipopus-pane.png as a high-resolution Kitty background and runs the TUI with --portrait slot to avoid terminal-cell pixelation.", + "Activities are Sith snack, duel practice, nap, cape compliment, helmet polish, and tiny mission." + ] + }, { "id": "quick-help", "name": "Quick Help", @@ -874,6 +845,30 @@ "stdout execution summary" ] }, + { + "id": "temp", + "name": "Cento Temporary Commands", + "lane": "ops", + "kind": "shell", + "entrypoint": "./scripts/cento_temp.sh", + "description": "One-command operator clipboard bridge that copies the fixed Markdown reference configured in scripts/cento_temp.sh through pbcopy.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento temp run" + ], + "outputs": [ + "clipboard", + "workspace/runs/temp/cento-ultimate-ai-reference.md" + ], + "notes": [ + "Only `cento temp run` is supported. Do not add ids, flags, list/show/add/remove, cross-node targets, secret prompts, or generated temp command registries.", + "To change what gets copied, edit only the `COPY_FILE` line in scripts/cento_temp.sh.", + "The wrapper validates the fixed Markdown file and runs `pbcopy < \"$COPY_FILE\"`; clipboard transport belongs in the local pbcopy shim, not in cento temp." + ] + }, { "id": "search-report", "name": "Search Report", @@ -893,24 +888,55 @@ "workspace/runs/search-report-*.md" ] }, + { + "id": "discord", + "name": "Discord Control", + "lane": "desktop ops", + "kind": "shell", + "entrypoint": "./scripts/restart_discord.sh", + "description": "Update, rerun, and inspect Discord through a Cento-native Linux desktop control command.", + "platforms": [ + "linux" + ], + "commands": [ + "cento discord status", + "cento discord update", + "cento discord update --rerun", + "cento discord rerun" + ], + "outputs": [ + "~/.local/opt/Discord", + "~/.config/discord/app-*", + "workspace/runs/discord/rerun-*.log", + "stderr status messages" + ], + "notes": [ + "cento discord update installs the latest official Linux tarball into the user profile without sudo.", + "cento discord rerun prefers the user-local install, bootstraps the Discord host without zenity, then falls back to system, Flatpak, or Snap launchers.", + "Use this when the packaged Discord host exits with a manual update requirement." + ] + }, { "id": "rd", "name": "Restart Discord", "lane": "desktop ops", "kind": "shell", "entrypoint": "./scripts/restart_discord.sh", - "description": "Terminate and relaunch Discord through the available desktop launcher.", + "description": "Compatibility shortcut for `cento discord rerun`.", "platforms": [ "linux" ], "commands": [ - "cento rd" + "cento rd", + "cento rd rerun" ], "outputs": [ + "workspace/runs/discord/rerun-*.log", "stderr status messages" ], "notes": [ - "Launches via discord, Discord, Flatpak com.discordapp.Discord, or Snap discord." + "Launches via the user-local Discord install, system discord, Discord, Flatpak com.discordapp.Discord, or Snap discord.", + "Prefer `cento discord update` and `cento discord rerun` for new automation." ] }, { @@ -1060,6 +1086,7 @@ "cento cluster status", "cento cluster exec linux -- tmux ls", "cento cluster exec macos -- cento gather-context --no-remote", + "CENTO_IPHONE_URL=http://iphone-cento.local:47919 cento cluster exec iphone -- health", "cento cluster sync", "cento cluster heal", "cento cluster heal linux", @@ -1085,6 +1112,7 @@ "sync is a read-only git drift report; it never writes to either node.", "heal is the single repair path for bridge services and sockets.", "Remote execution uses the existing OCI Unix-socket mesh.", + "The iPhone remains a companion node, but can expose an authenticated CentoMobile app control endpoint for health/status via cluster exec iphone.", "cluster_health_e2e.sh validates Mac-to-Linux execution paths used by agents before relying on the cluster.", "companion-setup prints a POSIX iPhone/iSH installer that preserves natural-language text by shell-quoting each SSH argument.", "Remote execution uses bash -lc on the target node, supports quoted shell commands and argv-style commands, repairs the Linux socket once when stale, and falls back to alice@alisapad.local when the LAN route is available." @@ -1115,6 +1143,37 @@ "Use this before cross-node work so agents can reason from current platform, repo, SSH, and tool availability facts." ] }, + { + "id": "mozilla-vpn", + "name": "Mozilla VPN Pane", + "lane": "desktop ops", + "kind": "shell", + "entrypoint": "./scripts/mozilla_vpn_tui.sh", + "description": "Native Mozilla VPN control pane for the Industrial OS workspace, with status, UI launch, login, activate, and deactivate actions.", + "platforms": [ + "linux" + ], + "commands": [ + "cento mozilla-vpn", + "cento mozilla-vpn --once", + "cento mozilla-vpn status", + "cento mozilla-vpn countries", + "cento mozilla-vpn select COUNTRY", + "cento mozilla-vpn ui", + "cento mozilla-vpn login", + "cento mozilla-vpn activate", + "cento mozilla-vpn deactivate" + ], + "outputs": [ + "interactive terminal control pane", + "mozillavpn native CLI/UI actions" + ], + "notes": [ + "The pane calls the installed mozillavpn binary directly and does not use a browser dashboard.", + "After login, j/k moves through loaded countries and c or Enter selects the first server hostname for that country.", + "Industrial OS uses this tool in the bottom-right tile that previously hosted quick actions." + ] + }, { "id": "network-tui", "name": "Cento Network Monitor", @@ -1142,41 +1201,27 @@ }, { "id": "agent-work", - "name": "Cento Taskstream CLI", + "name": "Agent Work Tracker", "lane": "agent ops", "kind": "python", "entrypoint": "./scripts/agent_work.py", - "description": "Cento Taskstream CLI for assigning, splitting, dispatching, reviewing, archiving, and cutting over Cento agent tasks across the Mac/Linux cluster.", + "description": "Lifecycle and governance substrate for Taskstream-backed Cento work: story/validation manifests, prompt handoff, dispatch/run ledgers, and review across the Mac/Linux cluster.", "platforms": [ "linux", "macos" ], "commands": [ "cento agent-work bootstrap", - "cento agent-work create --title \"Fix dashboard\" --manifest workspace/runs/agent-work/drafts/fix-dashboard/story.json --node linux --agent codex", - "CENTO_AGENT_WORK_BACKEND=dual cento agent-work create --title \"Validate parity\" --manifest workspace/runs/agent-work/drafts/validate-parity/story.json --node linux --agent codex", - "cento agent-work preflight workspace/runs/agent-work/no-model-validation-e2e/story.json --validation-manifest workspace/runs/agent-work/no-model-validation-e2e/validation.json", + "cento agent-work create --title \"Fix dashboard\" --node linux --agent codex", "cento agent-work split --title \"Improve mission control\" --nodes linux,macos --task \"Backend status\" --task \"Mac tile view\"", "cento agent-work list", "cento agent-work show 123", "cento agent-work claim 123 --node linux --agent codex", "cento agent-work update 123 --status review --note \"implemented and tested\"", - "CENTO_AGENT_WORK_BACKEND=dual cento agent-work update 123 --status validating --note \"builder update path check\"", - "CENTO_AGENT_WORK_BACKEND=dual cento agent-work validate 123 --result pass --note \"validation accepted\" --evidence workspace/runs/agent-work/validation-report.md", - "CENTO_AGENT_WORK_BACKEND=dual cento agent-work cutover-parity --all --run-dir workspace/runs/agent-work/cutover", - "cento agent-work backup --run-dir workspace/runs/agent-work/cutover/e2e-check", - "cento agent-work restore --bundle workspace/runs/agent-work/cutover/e2e-check/backup --verify", - "cento agent-work archive --query \"cutover\"", - "cento agent-work cutover-status", - "cento agent-work cutover-freeze", - "cento agent-work cutover-verify --run-dir workspace/runs/agent-work/cutover/e2e-check", - "cento agent-work cutover-finalize --force", - "cento agent-work review-drain --package mission-control --dry-run", - "cento agent-work review-drain --package mission-control --apply", "cento agent-work prompt 123", "cento agent-work dispatch 123 --node linux --dry-run", - "CENTO_AGENT_WORK_BACKEND=dual make agent-work-e2e", - "CENTO_AGENT_WORK_BACKEND=dual make agent-work-dual-backend-stress", + "cento agent-pool-kick --dry-run", + "cento agent-pool-kick --max-launch 2 --runtime codex --model gpt-5.3-codex-spark", "cento agent-work runs", "cento agent-work runs --json --active", "cento agent-work run-status RUN_ID --json" @@ -1184,271 +1229,285 @@ "outputs": [ "docs/agent-work.html", "docs/agent-run-ledger.md", - "Project identifier cento-agent-work", + "Taskstream project cento-agent-work", "workspace/runs/agent-runs//run.json", - "workspace/runs/agent-work//story.json", - "workspace/runs/agent-work//preflight.json", - "workspace/runs/agent-work//preflight.md", "workspace/runs/agent-work//prompt.md", "workspace/runs/agent-work//dispatch.json", - "workspace/runs/agent-work//codex.log", - "workspace/runs/agent-work/dual-backend-stress-/stress.log", - "workspace/runs/agent-work/dual-backend-stress-/concurrency-stress-report.md", - "workspace/runs/agent-work/review-drain//review-drain.md", - "workspace/runs/agent-work/review-drain//review-drain.json" + "workspace/runs/agent-work//codex.log" ], "notes": [ - "The active path uses Cento Taskstream; migration, archive, backup, and rollback drills stay separate from active task flow.", - "Task creation requires a valid story manifest; preflight requires a validation manifest, 95%+ automation coverage, and no unresolved manual_review items.", - "Dispatch runs preflight by default and blocks AI launch unless --skip-preflight is used for explicit legacy/manual dispatch.", - "Set `CENTO_AGENT_WORK_BACKEND=dual` for migration dual-write/dual-read parity checks between the migration source and replacement before cutover finalization.", - "Use `make agent-work-dual-backend-stress` to exercise concurrent UI reads, `/api/sync`, dual-backend writes, and cutover parity against an isolated replacement SQLite database.", + "The web app shell is the Cento Console with top-level Taskstream, Cluster, Consulting, and Docs sections.", + "Taskstream is the main tasking backend used by agent-work, the Taskstream section, and cluster dispatch.", + "Agent Work story.json and validation.json are the preferred human-visible task contract for Patch Swarm/Factory pilots.", "Use split to create one package with node-assigned work items, then dispatch or hand the generated prompt to an agent.", - "Use review-drain to dry-run approved Review closures before applying them; the command only mutates Review items and writes a transcript under workspace/runs/agent-work/review-drain//.", + "Use agent-pool-kick to keep cheap Spark/Codex workers busy; it is plan-only with --dry-run and launches workers when --dry-run is omitted.", "Statuses are Queued, Running, Review, Blocked, and Done." ] }, { - "id": "agent-manager", - "name": "Agent Manager", + "id": "compute-policy", + "name": "Compute Policy", "lane": "agent ops", "kind": "python", - "entrypoint": "./scripts/agent_manager.py", - "description": "Control-plane scanner for Cento agents that detects stale, idle, stuck, errored, duplicated, manual, and low-value runs and writes actionable reports.", + "entrypoint": "./scripts/compute_policy.py", + "description": "Manage provider-share policy for Codex, Claude Code, and metered OpenAI API use, then sync Agent Work runtime weights.", "platforms": [ "linux", "macos" ], "commands": [ - "cento agent-manager scan", - "cento agent-manager scan --json", - "cento agent-manager report", - "cento agent-manager recommend --limit 10", - "cento agent-manager classify --issue-id 81", - "cento agent-manager mark-stale RUN_ID --reason \"stuck validator\" --dry-run", - "cento agent-manager mark-blocked 81 --reason \"stuck validator\" --evidence RUN_ID --dry-run", - "cento agent-manager terminate-tmux cento-agent-81-095103 --reason \"stuck validator\" --dry-run", - "make agent-manager ARGS=\"pool-stats --json\"" + "cento compute-policy show", + "cento compute-policy show --json", + "cento compute-policy preset codex-first --json", + "cento compute-policy preset agent-preferred --json", + "cento compute-policy set --codex 85 --claude 15 --openai-api 0 --json", + "cento compute-policy apply --json" ], "outputs": [ - "docs/cento-agent-manager.html", - "workspace/runs/agent-manager/report-*/agent-manager-report.md", - "workspace/runs/agent-manager/report-*/agent-manager-report.json" + ".cento/compute-policy.json", + "data/agent-runtimes.json" ], "notes": [ - "Default management actions are dry-run unless --apply is passed.", - "The scanner correlates agent-work run ledgers, issue state, tmux sessions, process trees, logs, and pool state.", - "The Agent Processes TUI consumes the manager summary to surface risk, stuck, stale, and manual counts." + "Use this when you want to spend agent subscription or limit first and avoid metered API calls where agent dispatch can do the work.", + "When Codex/Claude weekly utilization is above 30%, prefer agent lanes for roughly 70-80% of eligible non-API-only work.", + "Codex and Claude shares become Agent Work weighted runtime values.", + "OpenAI API share is tracked for policy and analysis; explicit api-openai commands still require explicit operator/runtime selection.", + "Run `cento agent-work runtimes --sample 100 --json` after applying a policy to inspect the actual weighted route." ] }, { - "id": "factory", - "name": "Cento Factory", - "lane": "planning", + "id": "agent-pool-kick", + "name": "Agent Pool Kicker", + "lane": "agent ops", "kind": "python", - "entrypoint": "./scripts/factory.py", - "description": "Manifest-driven factory workflow that turns a high-level request into intake artifacts, a validated factory-plan.json, story manifests, validation manifests, queue ledgers, owned-path leases, worktree metadata, prompt bundles, patch collection, integration dry-runs, isolated Safe Integrator branches, per-patch validation, rollback metadata, release candidates, release status, Autopilot dry-run control cycles, runtime adapter contracts, and static evidence hubs without default AI dispatch.", + "entrypoint": "./scripts/agent_pool_kick.py", + "description": "Dry-run-first bounded worker-pool planner and launcher for builder, validator, small-task, and coordinator lanes without unbounded dispatch.", "platforms": [ "linux", "macos" ], "commands": [ - "cento factory intake \"develop me a career consulting module\" --dry-run --out workspace/runs/factory/factory-planning-e2e", - "cento factory plan workspace/runs/factory/factory-planning-e2e --no-model", - "cento factory materialize workspace/runs/factory/factory-planning-e2e", - "cento factory create-issues workspace/runs/factory/factory-planning-e2e --dry-run", - "cento factory preflight workspace/runs/factory/factory-planning-e2e --json", - "cento factory queue workspace/runs/factory/factory-planning-e2e", - "cento factory lease workspace/runs/factory/factory-planning-e2e --task crm-schema-extension --dry-run", - "cento factory dispatch workspace/runs/factory/factory-planning-e2e --lane builder --max 4 --dry-run", - "cento factory collect workspace/runs/factory/factory-planning-e2e", - "cento factory validate workspace/runs/factory/factory-planning-e2e", - "cento factory integrate workspace/runs/factory/factory-planning-e2e --dry-run", - "cento factory integrate factory-integration-e2e --plan", - "cento factory integrate factory-integration-e2e --prepare-branch --branch factory/factory-integration-e2e/integration", - "cento factory integrate factory-integration-e2e --apply --validate-each --limit 3", - "cento factory validate-integrated factory-integration-e2e", - "cento factory release-candidate factory-integration-e2e", - "cento factory sync-taskstream factory-integration-e2e --dry-run", - "cento factory release workspace/runs/factory/factory-planning-e2e --json", - "cento factory render-hub workspace/runs/factory/factory-planning-e2e", - "cento factory status workspace/runs/factory/factory-planning-e2e", - "cento factory autopilot factory-autopilot-runtime-e2e --dry-run --cycles 5", - "cento factory autopilot-status factory-autopilot-runtime-e2e --json", - "cento factory autopilot-render factory-autopilot-runtime-e2e", - "cento factory runtime list --json", - "cento factory runtime prepare factory-runtime-adapters-e2e --task factory-runtime-task-01 --runtime noop --dry-run", - "cento factory runtime launch factory-runtime-adapters-e2e --task factory-runtime-task-01 --runtime noop --dry-run", - "cento factory runtime status factory-runtime-adapters-e2e --task factory-runtime-task-01 --json", - "cento factory runtime collect factory-runtime-adapters-e2e --task factory-runtime-task-01", - "cento factory runtime cancel factory-runtime-adapters-e2e --task factory-runtime-task-01 --dry-run" + "cento agent-pool-kick --dry-run", + "cento agent-pool-kick --max-launch 3 --dry-run", + "cento agent-pool-kick --repair-missing-manifests --repair-apply --repair-lanes all --max-launch 0 --dry-run", + "cento agent-pool-kick --package claude-chores --runtime claude-code --model claude-sonnet-4-6 --max-launch 2", + "cento agent-pool-kick --max-launch 3 --model gpt-5.3-codex-spark", + "cento agent-pool-kick --builder-target 2 --validator-target 2 --small-target 1 --coordinator-target 1", + "python3 scripts/agent_pool_kick.py --dry-run" ], "outputs": [ - "workspace/runs/factory//request.md", - "workspace/runs/factory//intake.json", - "workspace/runs/factory//factory-plan.json", - "workspace/runs/factory//tasks//story.json", - "workspace/runs/factory//tasks//validation.json", - "workspace/runs/factory//queue/queue.json", - "workspace/runs/factory//queue/events.jsonl", - "workspace/runs/factory//queue/leases.json", - "workspace/runs/factory//tasks//worker-prompt.md", - "workspace/runs/factory//tasks//dispatch.json", - "workspace/runs/factory//tasks//worktree.json", - "workspace/runs/factory//patches//patch.json", - "workspace/runs/factory//dispatch-plan.json", - "workspace/runs/factory//integration/integration-plan.json", - "workspace/runs/factory//integration/dry-run-summary.md", - "workspace/runs/factory//integration/integration-state.json", - "workspace/runs/factory//integration/integration-branch.json", - "workspace/runs/factory//integration/apply-plan.json", - "workspace/runs/factory//integration/apply-log.jsonl", - "workspace/runs/factory//integration/applied-patches.json", - "workspace/runs/factory//integration/rejected-patches.json", - "workspace/runs/factory//integration/validation-after-each-patch.json", - "workspace/runs/factory//integration/quarantine//failure.json", - "workspace/runs/factory//integration/rollback-plan.json", - "workspace/runs/factory//integration/merge-readiness.json", - "workspace/runs/factory//integration/taskstream-sync-preview.json", - "workspace/runs/factory//integration/release-candidate.md", - "workspace/runs/factory//evidence/validation-summary.json", - "workspace/runs/factory//start-here.html", - "workspace/runs/factory//implementation-map.html", - "workspace/runs/factory//release-packet.md", - "workspace/runs/factory//delivery-status.json", - "workspace/runs/factory//autopilot/factory-state.json", - "workspace/runs/factory//autopilot/policy.json", - "workspace/runs/factory//autopilot/cycles//scan.json", - "workspace/runs/factory//autopilot/cycles//decision.json", - "workspace/runs/factory//autopilot/cycles//action.json", - "workspace/runs/factory//autopilot/cycles//result.json", - "workspace/runs/factory//autopilot/metrics.json", - "workspace/runs/factory//autopilot/stop-reason.json", - "workspace/runs/factory//autopilot/autopilot-summary.md", - "workspace/runs/factory//runtime//adapter-run.json", - "workspace/runs/factory//runtime//launch-plan.json", - "workspace/runs/factory//runtime//worker-ledger.json", - "workspace/runs/factory//runtime//heartbeat.json", - "workspace/runs/factory//runtime//status.json", - "workspace/runs/factory//runtime//cost.json", - "workspace/runs/factory//runtime//patch/patch.json", - "workspace/runs/factory//runtime//collect-result.json" + "~/.local/state/cento/agent-pool-kick-latest.json", + "stdout JSON launch summary", + "Taskstream issue state and agent run ledgers through agent-work dispatch" ], "notes": [ - "Factory defaults to no-model dry-runs. Live Taskstream issue creation requires --apply, and AI dispatch is never launched by default.", - "Every generated task has owned scope, expected outputs, validation commands, no-model eligibility, and dependency metadata.", - "Use factory-plan validation, story-manifest validation, validation-manifest validation, Agent Manager preflight, owned-path leases, runtime adapter contracts, patch collection, integration dry-run, Safe Integrator apply/validate gates, Autopilot cycle evidence, merge readiness, rollback metadata, and release status as required guardrails before worker dispatch or human merge review." + "Dry-run first before launching workers.", + "Use as the worker runtime planning surface after real queued work exists; do not create a duplicate Patch Swarm worker pool.", + "Use --repair-missing-manifests with --repair-lanes all to restore canonical story/validation manifests while keeping dispatch preflight enabled.", + "Defaults to the weighted runtime policy unless CENTO_AGENT_RUNTIME or --runtime overrides it.", + "Use --package to constrain dispatch to one Taskstream package before running cron or automation.", + "Defaults to the cheap Spark/Codex model unless CENTO_POOL_CODEX_MODEL, CENTO_POOL_CLAUDE_MODEL, or --model overrides it.", + "Uses current agent-work list and runs state to avoid dispatching issues that already have active runs.", + "Designed for a small cheap worker pool; use max-launch and target flags as guardrails." ] }, { - "id": "storage", - "name": "Cento Storage", - "lane": "platform ops", + "id": "claude-chores", + "name": "Claude Code Chores", + "lane": "agent ops", "kind": "python", - "entrypoint": "./scripts/storage.py", - "description": "No-delete artifact catalog and retention planner for Cento run ledgers, manifests, patches, validation logs, screenshots, SQLite snapshots, prompts, and release evidence before high-fanout Factory work increases artifact volume.", + "entrypoint": "./scripts/claude_chores.py", + "description": "Discover, document, schedule, and launch bounded Claude Code maintenance chores for Cento without metered OpenAI API spend.", "platforms": [ "linux", "macos" ], "commands": [ - "cento storage scan --root workspace/runs --db workspace/storage/catalog.sqlite", - "cento storage plan --dry-run", - "cento storage query --largest --limit 20", - "cento storage query --class screenshot_raw", - "cento storage pressure --json", - "cento storage normalize screenshots --dry-run", - "cento storage compress logs --dry-run", - "cento storage snapshot-db --path workspace/storage/catalog.sqlite --out workspace/storage/db-snapshots/catalog-snapshot.db", - "cento storage restore-test --sample 10", - "cento storage verify --all", - "cento storage report --out workspace/storage/reports/storage-summary.md", - "python3 scripts/storage_e2e.py --fixture mixed-artifacts --out workspace/runs/storage/cento-storage-v1" + "cento claude-chores plan --scope broad-repo --json", + "cento claude-chores run --scope broad-repo --chore-limit 2 --max-launch 2 --runtime claude-code --model claude-sonnet-4-6 --json", + "cento claude-chores run --scope broad-repo --chore-limit 2 --max-launch 2 --dry-run --json", + "cento claude-chores status --json", + "cento claude-chores install-cron --interval-minutes 30 --json", + "cento claude-chores uninstall-cron --json" ], "outputs": [ - "workspace/storage/catalog.sqlite", - "workspace/storage/reports/storage-summary.md", - "workspace/storage/reports/retention-plan.json", - "workspace/storage/reports/verify-report.json", - "workspace/storage/reports/storage-pressure.json", - "workspace/storage/reports/screenshot-normalization-plan.json", - "workspace/storage/reports/log-compression-plan.json", - "workspace/storage/db-snapshots/catalog-snapshot.db", - "workspace/storage/restore-tests//restore-test-report.json", - "workspace/runs/storage/cento-storage-v1/e2e-summary.md", - "workspace/runs/storage/cento-storage-v1/catalog.sqlite", - "workspace/runs/storage/cento-storage-v1/retention-plan.json", - "workspace/runs/storage/cento-storage-v1/storage-pressure.json", - "workspace/runs/storage/cento-storage-v1/verify-report.json" + "docs/claude-code-chores.md", + "workspace/runs/claude-chores//candidate_chores.json", + "workspace/runs/claude-chores//created_issues.json", + "workspace/runs/claude-chores//dispatch_summary.json", + "workspace/runs/claude-chores//claude-code-chores.md", + "workspace/runs/claude-chores/latest/status.json", + "~/.local/state/cento/claude-chores.log" ], "notes": [ - "Storage v1 is no-delete and no-cloud-upload. It catalogs, hashes, plans, verifies, and reports only.", - "Raw screenshots are normalization/compression candidates until normalized derivatives and restore checks exist.", - "SQLite DB/WAL artifacts require controlled snapshot and integrity handling before future movement.", - "Autopilot should eventually treat storage pressure as a backpressure gate before increasing Factory fanout." + "Default policy is controlled saturation: every 30 minutes, create at most two chores and launch at most two Claude Code workers.", + "The worker pool is constrained to the claude-chores package so cron does not launch unrelated queued work.", + "When Codex/Claude utilization is above 30%, prefer agent lanes for roughly 70-80% of eligible non-API-only work.", + "The loop uses Claude Code subscription capacity and does not route chores through metered OpenAI API workers.", + "Use --crontab-file in tests or dry runs so the real user crontab is not modified." ] }, { - "id": "agent-work-app", - "name": "Cento Console App", + "id": "walk-autopilot", + "name": "Walk Autopilot", "lane": "agent ops", "kind": "python", - "entrypoint": "./scripts/agent_work_app.py", - "description": "Self-hosted Cento Console web app with Taskstream, Cluster, Consulting, and Docs sections, plus background process control, health checks, and migration import sync.", + "entrypoint": "./scripts/walk_autopilot.py", + "description": "Append-only follow-up coordinator for bounded Factory, spend-ledger, Hard ProReq, image fallback, agent-work hygiene, and worker-pool loops.", "platforms": [ "linux", "macos" ], "commands": [ - "cento agent-work-app start", - "cento agent-work-app stop", - "cento agent-work-app status", - "cento agent-work-app import-redmine", - "cento agent-work-app install-sync", - "cento agent-work backup", - "cento agent-work restore --bundle workspace/runs/agent-work/cutover/e2e-check/backup --verify", - "cento agent-work archive --query \"migration\"", - "cento agent-work cutover-status" + "cento walk-autopilot run --loops 12 --cadence-seconds 1200 --soft-cap-usd 12 --hard-cap-usd 20", + "cento walk-autopilot start-tmux --loops 12 --cadence-seconds 1200 --hard-cap-usd 20 --allow-live-api --dashboard-total-spend-usd 0 --notify-target iphone", + "cento walk-autopilot run --loops 1 --cadence-seconds 0", + "cento walk-autopilot start-tmux --loops 12 --cadence-seconds 1200 --notify-target iphone", + "cento walk-autopilot status", + "cento walk-autopilot review-unblock run --mode report --json", + "cento walk-autopilot review-unblock run --mode aggressive --json", + "cento walk-autopilot review-unblock status --json", + "cento walk-autopilot run --live-workers --review-unblock-mode aggressive", + "cento walk-autopilot patch-swarm run --candidate-target 100 --max-parallel-agents 5 --json", + "cento walk-autopilot patch-swarm status --json", + "cento walk-autopilot routing run --json", + "cento walk-autopilot routing status --json", + "cento walk-autopilot routing install-cron --every-hours 4 --json", + "cento walk-autopilot routing uninstall-cron --json", + "cento walk-autopilot factory-scale start --duration-hours 6 --proreq-executions 30 --min-proreq-calls 100 --patch-swarm --json", + "cento walk-autopilot factory-scale start-day --target-proreq-calls 3000 --max-proreq-calls 10000 --duration-hours 12 --batch-size 5 --json", + "cento walk-autopilot factory-scale preflight --run-id RUN_ID --json", + "cento walk-autopilot factory-scale advance --run-id RUN_ID --promotion-limit 25 --json", + "cento walk-autopilot factory-scale promote --run-id RUN_ID --limit 100 --factory-run workspace/runs/factory/factory-scale-promotion-RUN_ID --json", + "cento walk-autopilot factory-scale tick --run-id RUN_ID --batch-size 5 --json", + "cento walk-autopilot factory-scale status --run-id RUN_ID --json", + "cento walk-autopilot factory-scale install-cron --run-id RUN_ID --duration-hours 6 --json", + "cento walk-autopilot factory-scale uninstall-cron --json" ], "outputs": [ - "~/.local/state/cento/agent-work-app.pid", - "~/.local/state/cento/agent-work-app.log", - "~/.local/state/cento/agent-work-app-sync.log", - "~/.local/state/cento/agent-work-app.sqlite3", - "http://127.0.0.1:47910/health" + "workspace/runs/walk-autopilot//metrics.jsonl", + "workspace/runs/walk-autopilot//spend-ledger.jsonl", + "workspace/runs/walk-autopilot//notes.md", + "workspace/runs/walk-autopilot//loops/loop-0001.md", + "workspace/runs/walk-autopilot//incidents//incident.json", + "workspace/runs/walk-autopilot//handoff.md", + "workspace/runs/walk-autopilot//review-unblock/loop-0001/decision.json", + "workspace/runs/walk-autopilot//review-unblock/loop-0001/decision_report.md", + "workspace/runs/walk-autopilot//review-unblock/loop-0001/actions.jsonl", + "workspace/runs/walk-autopilot/review-unblock//snapshot.json", + "workspace/runs/walk-autopilot/review-unblock//decision.json", + "workspace/runs/walk-autopilot/review-unblock//decision_report.md", + "workspace/runs/walk-autopilot/review-unblock/latest/", + "workspace/runs/walk-autopilot/routing-native//raw_counts.json", + "workspace/runs/walk-autopilot/routing-native//decision.json", + "workspace/runs/walk-autopilot/routing-native//decision_report.md", + "workspace/runs/walk-autopilot/routing-native//agent_work_request.json", + "workspace/runs/walk-autopilot/routing-native//next_iteration.md", + "workspace/runs/walk-autopilot/routing-native/latest/", + "workspace/runs/walk-autopilot/factory-scale-/roadmap.md", + "workspace/runs/walk-autopilot/factory-scale-/config.json", + "workspace/runs/walk-autopilot/factory-scale-/events.jsonl", + "workspace/runs/walk-autopilot/factory-scale-/thoughts.jsonl", + "workspace/runs/walk-autopilot/factory-scale-/proreq-light-calls.jsonl", + "workspace/runs/walk-autopilot/factory-scale-/metrics.jsonl", + "workspace/runs/walk-autopilot/factory-scale-/spend-ledger.jsonl", + "workspace/runs/walk-autopilot/factory-scale-/handoff.md", + "workspace/runs/walk-autopilot/factory-scale-/cron.md", + "workspace/runs/walk-autopilot/factory-scale-/proreq-executions/exec-001/", + "workspace/runs/walk-autopilot/factory-scale-/patch-swarm/milestone-01/", + "workspace/runs/walk-autopilot/factory-scale-/advance/no-overlap-preflight.json", + "workspace/runs/walk-autopilot/factory-scale-/advance/live-api-guard.json", + "workspace/runs/walk-autopilot/factory-scale-/advance/candidate-matrix.json", + "workspace/runs/walk-autopilot/factory-scale-/advance/safe-integrator-promotion-plan.json", + "workspace/runs/walk-autopilot/factory-scale-/advance/factory-promotion-.json", + "workspace/runs/walk-autopilot/factory-scale-/advance/morning-report.md" + ], + "docs": [ + "docs/ai-review-unblock-autopilot.md", + "docs/ai-routing-nativeness-loop.md", + "docs/agent-work-live-dispatch-incident.md", + "docs/factory-1000-patch-swarm-roadmap.md", + "docs/walk-autopilot-spend-cap-incident.md" ], "notes": [ - "Start writes a PID file in ~/.local/state/cento and binds Cento Console to 127.0.0.1:47910.", - "Use status to probe the /health endpoint for the background app.", - "install-sync adds a 5-minute cron job that keeps the Taskstream database current during migration and writes cron output to ~/.local/state/cento/agent-work-app-sync.log.", - "import-redmine syncs the local replacement database from the current migration-backed agent-work CLI." + "Each loop writes one Markdown summary with required AI handoff sections.", + "Each loop appends one metrics record and one spend summary record.", + "Factory dry-runs are recorded separately from explicit Pro/image/API spend.", + "Live worker launch failures are treated as incidents with bundled evidence, manifest repair, bounded retry, and recovery-plan artifacts when needed.", + "Live worker and live API behavior require explicit flags and remain bounded by soft/hard spend caps.", + "Live API lanes require an OpenAI dashboard total snapshot via --dashboard-total-spend-usd or CENTO_OPENAI_DASHBOARD_TOTAL_SPEND_USD; the hard cap applies to dashboard total spend, not only the local run ledger.", + "The Review/Unblock stage scans Review, Blocked, Validating, and stale run states each loop and chooses close, validate, requeue, repair-task, archive, or operator escalation actions.", + "Review/Unblock defaults to report mode unless --live-workers is enabled or --review-unblock-mode aggressive is passed.", + "Review/Unblock never closes Review items directly; it routes closures through agent-work review-drain, which requires validation pass plus evidence.", + "The routing nativeness subcommands run a lightweight counts-only observability loop on a four-hour cron cadence.", + "Routing cron writes reports and creates or updates one bounded Agent Work task for actionable changes; it does not implement code from cron.", + "Routing artifacts mirror to workspace/runs/walk-autopilot/routing-native/latest/.", + "Factory scale final test subcommands initialize a six-hour, 30-execution ProReq-light ledger run and derive status from append-only JSONL records.", + "Factory scale day mode derives ProReq-light executions from a target command-call count, defaults to 3,000 expected calls, enforces a 10,000-call ceiling, and advances in guarded batch ticks.", + "Factory scale preflight detects existing cron, run status, and active factory-scale/proreq/patch-swarm processes before start or advance creates any new work.", + "Factory scale advance reuses a completed 1,000-candidate run, indexes candidate receipts, writes a Safe Integrator promotion plan, and keeps live OpenAI/API disabled unless dashboard spend and rate-limit gates pass.", + "Factory scale promote turns advance promotion-plan entries into exclusive-path Factory patch bundles, apply plans, and parallel validation-fanout receipts; optional apply stays behind Factory/Safe Integrator worktree gates.", + "Factory scale cron uses the marked CENTO FACTORY SCALE FINAL TEST block, configurable cadence, flock, batch-size ticks, and a deadline check.", + "Every third factory-scale ProReq-light execution runs Patch Swarm fixture e2e for 100 candidate receipts and a Safe Integrator handoff." ] }, { - "id": "story-screenshot-runner", - "name": "Story Screenshot Runner", + "id": "agent-work-hygiene", + "name": "Agent Work Hygiene", "lane": "agent ops", - "kind": "python", - "entrypoint": "./scripts/story_screenshot_runner.py", - "description": "Read screenshot requirements from story.json, capture desktop and mobile evidence with Playwright, and write deterministic metadata plus an index for Docs/Evidence and Validator lanes.", + "kind": "shell", + "entrypoint": "./scripts/agent_work_hygiene.sh", + "description": "Collect a point-in-time reconciliation report of agent run ledgers, tmux sessions, and Codex/Claude processes.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento agent-work-hygiene", + "cento agent-work-hygiene --issue 94", + "cento agent-work-hygiene --out-dir workspace/runs/agent-work/reconciliation", + "./scripts/agent_work_hygiene.sh" + ], + "outputs": [ + "workspace/runs/agent-work/reconciliation/hygiene-*/hygiene-report.md", + "workspace/runs/agent-work/reconciliation/hygiene-*/agent-work-runs.json", + "workspace/runs/agent-work/reconciliation/hygiene-*/tmux-sessions.txt", + "workspace/runs/agent-work/reconciliation/hygiene-*/process-probe.txt" + ], + "notes": [ + "Use before dispatching more workers when stale run records or blocked pool state are confusing capacity.", + "The report is evidence-first and does not mutate source code.", + "Pass --issue to scope reconciliation to one tracked run family." + ] + }, + { + "id": "agent-processes", + "name": "Agent Processes Dashboard", + "lane": "agent ops", + "kind": "shell", + "entrypoint": "./scripts/agent_processes_tui.sh", + "description": "Read-only process and worker visibility for cluster-wide managed/manual agent sessions, stale/risk indicators, and queue pressure.", "platforms": [ "linux", "macos" ], "commands": [ - "cento story-screenshot-runner workspace/runs/agent-work/59/story.json", - "cento story-screenshot-runner workspace/runs/agent-work/59/story.json --force", - "./scripts/story_screenshot_runner.py workspace/runs/agent-work/59/story.json --force" + "cento agent-processes", + "cento agent-processes --once", + "./scripts/agent_processes_tui.sh", + "./scripts/agent_processes_tui.sh --once" ], "outputs": [ - "workspace/runs/agent-work//screenshot-evidence.json", - "workspace/runs/agent-work//screenshot-index.md", - "workspace/runs/agent-work//screenshots/-.png" + "interactive terminal dashboard", + "plain dashboard text when stdout is not a TTY" ], "notes": [ - "Reads screenshots[] from story.json, prefers any explicit output path, and otherwise writes deterministic issue/name/viewport filenames under the run directory.", - "Writes URL, auth/token notes, viewport dimensions, issue id, command, and summary data into screenshot-evidence.json.", - "Returns non-zero with a clear status when the URL is unavailable or Playwright cannot be launched." + "Data comes from `python3 scripts/agent_work.py runs --json --active` and `python3 scripts/agent_work.py list --json`.", + "Use this for Patch Swarm runtime visibility before adding a new worker dashboard or process state model.", + "Attempts `python3 scripts/agent_manager.py scan --json` for risk/stale/manual counts when available.", + "If manager scan is unavailable, the dashboard remains usable from runs/list data alone.", + "Press r to refresh, q/Ctrl-C to quit.", + "Use --once for CI and non-interactive output." ] }, { @@ -1540,27 +1599,565 @@ ] }, { - "id": "temp", - "name": "Cento Temporary Commands", - "lane": "ops", - "kind": "shell", - "entrypoint": "./scripts/cento_temp.sh", - "description": "Short-lived operator wrappers for fragile one-off commands that should not be pasted as multiline shell.", + "id": "demo-evidence", + "name": "Demo Evidence Recorder", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/demo_evidence.py", + "description": "Operator evidence utility for short 10-30 second desktop demo videos after real Factory, Codex worker, or validation flows exist.", "platforms": [ - "linux" + "linux", + "macos" + ], + "commands": [ + "cento demo-evidence record --title \"Factory UI walkthrough\" --duration 15", + "cento demo-evidence record --factory-run workspace/runs/factory/ --task --worker --duration 15 --notes \"Shows accepted flow\"", + "cento demo-evidence record --duration 10 --recorder synthetic --out workspace/runs/demo-evidence/smoke --json", + "cento demo-evidence record --duration 15 --dry-run --json", + "cento demo-evidence verify workspace/runs/demo-evidence/", + "cento demo-evidence status workspace/runs/demo-evidence/ --json" + ], + "outputs": [ + "workspace/runs/demo-evidence//demo.mp4", + "workspace/runs/demo-evidence//receipt.json", + "workspace/runs/demo-evidence//summary.md", + "workspace/runs/factory//tasks//evidence/demo-*/demo.mp4", + "workspace/runs/factory//tasks//evidence/demo-*/receipt.json" + ], + "notes": [ + "Use this after a Builder or Codex worker has a visible product flow to prove, especially before Factory validation or release handoff.", + "Demo evidence is proof for a real flow, not a substitute for a real pilot, patch, integration dry-run, or honest blocker.", + "The tool enforces a 10-30 second duration window and records requested duration, measured duration, recorder backend, video hash, and paths in receipt.json.", + "Linux auto mode prefers wf-recorder on Wayland and ffmpeg x11grab on X11; macOS uses ffmpeg avfoundation and may require Screen Recording permission.", + "Pass --factory-run and --task to colocate evidence under the Factory task bundle.", + "Use --dry-run for planning and --recorder synthetic only for smoke testing the evidence plumbing, not for product proof.", + "See docs/demo-evidence.md for worker handoff and troubleshooting guidance." + ] + }, + { + "id": "factory", + "name": "Cento Factory", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/factory.py", + "description": "Orchestration substrate for deterministic intake, planning, materialization, queueing, dry-run dispatch, patch collection, validation, integration, release candidates, and hubs.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento factory --help", + "cento factory intake \"develop me a career consulting module\" --dry-run --out workspace/runs/factory/factory-planning-e2e", + "cento factory plan workspace/runs/factory/factory-planning-e2e --no-model", + "cento factory materialize workspace/runs/factory/factory-planning-e2e", + "cento factory queue workspace/runs/factory/factory-planning-e2e", + "cento factory dispatch workspace/runs/factory/factory-planning-e2e --lane builder --max 4 --dry-run", + "cento factory collect workspace/runs/factory/factory-planning-e2e", + "cento factory validate workspace/runs/factory/factory-planning-e2e", + "cento factory integrate workspace/runs/factory/factory-planning-e2e --dry-run", + "cento factory validate-fanout factory-integration-e2e --max-parallel 32 --json", + "cento factory merge factory-integration-e2e --auto-merge-main --dry-run --json", + "cento factory merge factory-integration-e2e --auto-merge-main --push --json", + "cento factory status workspace/runs/factory/factory-planning-e2e" + ], + "outputs": [ + "workspace/runs/factory//factory-plan.json", + "workspace/runs/factory//queue.json", + "workspace/runs/factory//integration/apply-plan.json", + "workspace/runs/factory//integration/validation-fanout.json", + "workspace/runs/factory//integration/merge-receipt.json" + ], + "notes": [ + "Factory remains deterministic by default. Use `cento build` for the manifest-owned local build package v1 slice.", + "Factory is the preferred execution spine for real Patch Swarm pilots; adapt facade/status gaps instead of creating a new runtime.", + "Live Taskstream creation and patch application remain explicit opt-in operations on Factory commands.", + "Factory validate-fanout runs cacheable candidate checks in parallel before serialized Safe Integrator apply.", + "Factory merge --auto-merge-main is the only automatic main/push gate and requires release, rollback, validation, clean-worktree, and post-merge receipts.", + "Factory merge --auto-merge-main --dry-run writes merge readiness evidence without merging or pushing." + ] + }, + { + "id": "build", + "name": "Cento Build", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/cento_build.py", + "description": "Patch unit and safety substrate for manifest-owned paths, Builder prompts, patch bundles, dry-run integration, safe apply, and receipts.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento build --help", + "cento build init --task \"Fixture docs page patch\" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture", + "cento build check tests/fixtures/cento_build/manifest.valid.json", + "cento build prompt tests/fixtures/cento_build/manifest.valid.json", + "cento build artifact check tests/fixtures/cento_build/worker_artifact.valid.json", + "cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180", + "cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime-profile codex-fast --worktree", + "cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime command --command \"codex exec --prompt-file {prompt}\" --allow-unsafe-command --worktree --timeout 180", + "cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff", + "cento build integrate .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --worktree --dry-run", + "cento build apply .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --from-receipt .cento/builds//integration_receipt.json", + "cento build receipt .cento/builds/build_fixture_docs_page_001" + ], + "outputs": [ + ".cento/builds//manifest.json", + ".cento/builds//builder.prompt.md", + ".cento/builds//workers/builder_1/worker_artifact.json", + ".cento/builds//workers/builder_1/patch_bundle.json", + ".cento/builds//integration_receipt.json", + ".cento/builds//apply_receipt.json", + ".cento/builds//validation_receipt.json", + ".cento/builds//taskstream_evidence.json", + ".cento/builds//events.ndjson" + ], + "notes": [ + "Local-only v1.2; one fixture/local worker can be launched, but there are no cloud workers, API calls, scheduler, PR creation, or automatic model patch generation.", + "Treat Build patch bundles and integration receipts as canonical before adding Patch Swarm bundle or apply formats.", + "Use `cento runtime check codex-fast` and `--runtime-profile codex-fast` for hardened command runtime profiles. Raw shell command runtimes require `--allow-unsafe-command`.", + "The normal integration path requires a patch bundle. Raw patch integration is rejected unless explicitly run as a dev raw-patch path.", + "The core acceptance behavior is rejecting dirty owned paths, unowned paths, protected paths, binary patches, path traversal, and undeclared lockfile changes.", + "`cento build apply` requires an accepted integration receipt and writes apply/taskstream evidence receipts." + ] + }, + { + "id": "runtime", + "name": "Cento Runtime Profiles", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/cento_runtime.py", + "description": "Inspect and validate local builder runtime profiles used by Cento Build worker execution.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento runtime list", + "cento runtime list --json", + "cento runtime check codex-fast", + "cento runtime check codex-fast --json", + "cento runtime check claude-code-fast --json", + "cento runtime check python-fixture --require-executable" + ], + "outputs": [ + ".cento/runtimes.yaml" + ], + "notes": [ + "Runtime profiles use argv arrays, scrubbed environment allowlists, explicit timeouts, and isolated worktrees for command workers.", + "`claude-code-fast` is available as the Claude Code command-runtime adapter used by Patch Swarm.", + "`cento runtime check` validates the profile contract. Missing executables are warnings unless `--require-executable` is passed.", + "This tool does not launch workers; `cento build worker run --runtime-profile NAME --worktree` owns execution." + ] + }, + { + "id": "workset", + "name": "Cento Workset", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/cento_workset.py", + "description": "Parallel lease substrate for exclusive-path N-worker tasks, structured API artifacts, dependency gates, budget caps, and sequential integration.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento workset check tests/fixtures/cento_workset/workset.valid.json", + "cento workset check tests/fixtures/cento_workset/workset.execute.api.json --runtime api-openai", + "cento workset check tests/fixtures/cento_workset/workset.overlap.json", + "cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke", + "cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke", + "cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke", + "cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke", + "cento workset materialize-artifact .cento/worksets//workers//artifact.json" + ], + "outputs": [ + ".cento/worksets//workset.json", + ".cento/worksets//leases.json", + ".cento/worksets//workset_receipt.json", + ".cento/worksets//workset_evidence.json", + ".cento/worksets//events.ndjson", + ".cento/worksets//workers//artifact.json", + ".cento/worksets//workers//cost_receipt.json", + ".cento/worksets//workers//worker_receipt.json", + ".cento/builds/workset__/*" + ], + "notes": [ + "Workset v1 rejects overlapping write paths and glob write paths. Every task must have exclusive write_paths.", + "Use Workset exclusive paths as the source of truth for parallel lease semantics before adding Patch Swarm lease concepts.", + "Plain `cento workset check WORKSET` rejects missing write paths. API-worker-created file plans must declare `--runtime api-openai` or `--allow-creates`.", + "Workers run in parallel only until patch or structured artifact collection. Integration and apply are always sequential.", + "OpenAI API workers use Responses API structured outputs and do not mutate repo files directly.", + "API worker budgets have a target and hard max; budget-blocked workers still write cost receipts.", + "Dependency gates are intentionally simple: a task dispatches only after depends_on tasks are completed and applied.", + "Shared-file edits require a separate serialized integrator task; no smart merge or conflict resolution is attempted." + ] + }, + { + "id": "object-storage", + "name": "Oracle Object Storage", + "lane": "cloud ops", + "kind": "python", + "entrypoint": "./scripts/object_storage.py", + "description": "Write dummy objects and mirror Cento run images to private Oracle Object Storage through the OCI CLI.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento object-storage status", + "cento object-storage status --probe --json", + "cento object-storage ensure-bucket --name cento-images-standard --region us-ashburn-1 --namespace NAMESPACE --json", + "cento object-storage put-dummy --dry-run --json", + "cento object-storage put-dummy --region us-ashburn-1 --bucket CENTO_BUCKET --namespace NAMESPACE --json", + "cento object-storage e2e --json", + "cento object-storage e2e --live --region us-ashburn-1 --bucket CENTO_BUCKET --namespace NAMESPACE --json", + "cento object-storage plan-images --root workspace/runs --bucket cento-images-standard --namespace NAMESPACE --region us-ashburn-1 --json", + "cento object-storage upload-images --manifest workspace/runs/object-storage//manifest.json --live --json", + "cento object-storage verify-images --manifest workspace/runs/object-storage//upload-receipt.json --sample 10 --json" + ], + "outputs": [ + "workspace/runs/object-storage//dummy.txt", + "workspace/runs/object-storage//receipt.json", + "workspace/runs/object-storage//summary.md", + "workspace/runs/object-storage//e2e-summary.json", + "workspace/runs/object-storage//e2e-summary.md", + "workspace/runs/object-storage//manifest.json", + "workspace/runs/object-storage//upload-receipt.json", + "workspace/runs/object-storage//verify-receipt.json", + "OCI object: oci:////" + ], + "docs": [ + "docs/oci-image-migration.html", + "docs/oci-image-migration.md" + ], + "notes": [ + "Uses the installed OCI CLI instead of adding a Python SDK dependency.", + "Bucket defaults to CENTO_OBJECT_STORAGE_BUCKET; namespace defaults to CENTO_OBJECT_STORAGE_NAMESPACE and otherwise lets the OCI CLI discover it.", + "Region can be passed with --region or CENTO_OBJECT_STORAGE_REGION when the default OCI config region is not the Object Storage region to use.", + "Dry-run mode never calls OCI; it copies the dummy file to uploaded/ and verifies the hash.", + "Image migration is mirror-only: local originals are never deleted, truncated, or replaced.", + "Image objects are content-addressed by sha256 and sensitive-looking paths are blocked from upload.", + "Human runbook: docs/oci-image-migration.html; Markdown source: docs/oci-image-migration.md" + ] + }, + { + "id": "proreq-light", + "name": "ProReq Light", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/proreq_light.py", + "description": "Run the Hard ProReq artifact chain with the Pro planning request replaced by Codex Exec using a ChatGPT Pro simulation prompt.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento proreq-light all", + "cento proreq-light pro-request", + "cento proreq-light codex-plan", + "cento proreq-light backend-work", + "cento proreq-light validation-plan", + "cento proreq-light deliver --max-parallel 3 --runtime-profile codex-fast --json" + ], + "outputs": [ + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//proreq_light_codex_prompt.md", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//proreq_light_output_schema.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//proreq_light_codex_command.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//proreq_light_codex_response.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//pro_backend_plan.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//backend_work_manifest.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//validation_plan.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//closed_loop_delivery.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//closed_loop_evidence.md", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq//closed_loop_incident.md" + ], + "docs": [ + "docs/dev-pipeline-run-contracts.md" + ], + "notes": [ + "The prompt starts with `You're chatGPT Pro model` and asks read-only Codex Exec to emulate the Hard ProReq Pro planning lane.", + "The output schema remains `cento.hard_proreq_backend_plan.v1`, so downstream story/workset/integration artifacts stay compatible.", + "`deliver` turns accepted ProReq-light worksets into local Codex worker launches, sequential integration, validation, evidence, and incident receipts.", + "This route does not use live OpenAI Pro API, image API dispatch, or OpenAI API workers; if Codex Exec is unavailable or times out, it records the issue and falls back to deterministic planning." + ] + }, + { + "id": "foundry", + "name": "Cento Tool Foundry", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/tool_foundry.py", + "description": "Create Cento-native business tools through Factory, Workset, parallel train promotion, storage policy, cost receipts, and demo evidence.", + "platforms": [ + "linux", + "macos" + ], + "commands": [ + "cento foundry create \"client intake hub\" --domain career-consulting --max-parallel 6 --budget-usd 10 --max-budget-usd 20 --json", + "cento foundry plan RUN_ID --json", + "cento foundry execute RUN_ID --runtime fixture --json", + "cento foundry execute RUN_ID --runtime api-openai --budget-usd 10 --max-budget-usd 20 --json", + "cento foundry promote RUN_ID --dry-run --json", + "cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --dry-run --json", + "cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --apply --json", + "cento foundry status RUN_ID --json", + "cento foundry validate RUN_ID --json", + "cento foundry e2e --fixture client-intake-hub --dry-run --json", + "cento foundry e2e --fixture client-intake-hub --dry-run --real-files --target-root templates/foundry/client-intake-hub --json", + "cento foundry e2e --fixture client-intake-hub --live --budget-usd 10 --max-budget-usd 20 --json" + ], + "outputs": [ + "workspace/runs/foundry//foundry-spec.json", + "workspace/runs/foundry//factory_handoff.json", + "workspace/runs/foundry//workset.json", + "workspace/runs/foundry//workset_check.json", + "workspace/runs/foundry//execution_receipt.json", + "workspace/runs/foundry//cost_receipt.json", + "workspace/runs/foundry//storage-policy.json", + "workspace/runs/foundry//demo-evidence.json", + "workspace/runs/foundry//real_file_manifest.json", + "workspace/runs/foundry//materialization_plan.json", + "workspace/runs/foundry//materialization_receipt.json", + "workspace/runs/foundry//validation_summary.json", + "templates/foundry/client-intake-hub/", + "docs/client-intake-hub.md", + "workspace/runs/parallel-delivery/train/foundry--train/", + "workspace/runs/factory/parallel-train-foundry--train/" + ], + "docs": [ + "docs/tool-foundry.md", + "docs/client-intake-hub.md" + ], + "notes": [ + "Foundry is a facade over existing Cento primitives; it does not introduce a second scheduler or integrator.", + "The first fixture tool is the career consulting Client Intake Hub.", + "Dry-run/fixture mode costs $0 and is the required repeatable validation path.", + "Live api-openai execution requires both --budget-usd and --max-budget-usd, and v1 rejects hard caps above $20.", + "Client data is local-first; real resumes, LinkedIn exports, notes, and PII are never uploaded by default.", + "Workset execution uses tracked fixture targets while run-scoped artifacts carry the generated product bundle and evidence.", + "Real-file materialization plans or applies repo-ready Client Intake Hub files under templates/foundry/client-intake-hub plus docs/client-intake-hub.md.", + "Materialization dry-run is the default; apply skips identical files and blocks changed existing targets instead of overwriting them." + ] + }, + { + "id": "parallel-delivery", + "name": "Parallel AI Delivery", + "lane": "agent ops", + "kind": "python", + "entrypoint": "./scripts/parallel_delivery.py", + "description": "Patch Swarm and Parallel AI Delivery product facade over Factory orchestration, Build patch units, Workset leases, Agent Work lifecycle, and worker visibility.", + "platforms": [ + "linux", + "macos" ], "commands": [ - "cento run temp 1", - "cento run temp 1 status", - "cento run temp 1 rollback" + "cento parallel-delivery plan --json", + "cento parallel-delivery execute --sleep-seconds 1 --json", + "cento parallel-delivery execute --live-pro --sleep-seconds 1 --json", + "cento parallel-delivery demo --json", + "cento parallel-delivery validate --json", + "cento parallel-delivery status --json", + "cento parallel-delivery train plan --workset tests/fixtures/cento_workset/workset.valid.json --max-parallel 10 --json", + "cento parallel-delivery train run RUN_ID --simulate --json", + "cento parallel-delivery train run RUN_ID --workset-execute --runtime fixture --validation smoke --allow-dirty-owned --json", + "cento parallel-delivery train promote RUN_ID --dry-run --json", + "cento parallel-delivery train e2e --workset tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --allow-dirty-owned --dry-run --json", + "cento parallel-delivery train integrate RUN_ID --dry-run --json", + "cento parallel-delivery train status RUN_ID --json", + "cento parallel-delivery train validate RUN_ID --json", + "cento parallel-delivery patch-swarm plan --candidate-target 100 --max-parallel-agents 5 --json", + "cento parallel-delivery patch-swarm split --request-file REQUEST.md --candidate-target 20 --max-parallel-agents 5 --mode no-model --json", + "cento parallel-delivery patch-swarm leases --run-dir workspace/runs/parallel-delivery/lease-fixture --run-id lease-fixture --fixture --json", + "cento parallel-delivery patch-swarm validate-leases --run-dir workspace/runs/parallel-delivery/lease-fixture --json", + "cento parallel-delivery patch-swarm prompts --run-dir workspace/runs/parallel-delivery/proreq-fixture --count 20 --lane all --chatgpt-pro --copy-to-temp --json", + "cento parallel-delivery patch-swarm worker-packets --run-dir workspace/runs/parallel-delivery/codex-packets-fixture --run-id codex-packets-fixture --fixture --count 10 --json", + "cento parallel-delivery patch-swarm dispatch --run-dir workspace/runs/parallel-delivery/worker-status-fixture --run-id worker-status-fixture --candidate-target 100 --max-parallel-agents 5 --dry-run --fixture --json", + "cento parallel-delivery patch-swarm worker-status --run-dir workspace/runs/parallel-delivery/worker-status-fixture --json", + "cento parallel-delivery status --run worker-status-fixture --run-root workspace/runs/parallel-delivery --json", + "cento parallel-delivery patch-bundles validate --bundle workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles/bundle-safe-001.json --lease-manifest workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json --out workspace/runs/parallel-delivery/patch-bundle-fixture --base-commit HEAD --json", + "cento parallel-delivery patch-bundles collect --run-id patch-bundle-fixture --bundles-dir workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles --lease-manifest workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json --out workspace/runs/parallel-delivery/patch-bundle-fixture --base-commit HEAD --json", + "cento parallel-delivery release-candidate create --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json --out workspace/runs/parallel-delivery/release-candidate-fixture/dry-run --mode dry-run --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo --base-commit HEAD --json", + "cento parallel-delivery release-candidate create --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json --out workspace/runs/parallel-delivery/release-candidate-fixture/apply --mode apply --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo --target-worktree workspace/runs/parallel-delivery/release-candidate-fixture/integration-worktree --base-commit HEAD --final-validation-cmd \"python -m pytest -q tests\" --json", + "cento parallel-delivery taskstream emit --split-plan workspace/runs/parallel-delivery/taskstream-fixture/input/split-plan.json --out workspace/runs/parallel-delivery/taskstream-fixture --transport manifest-only --run-preflight", + "cento parallel-delivery taskstream preflight --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages --out workspace/runs/parallel-delivery/taskstream-fixture/preflight", + "cento parallel-delivery taskstream apply --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages --out workspace/runs/parallel-delivery/taskstream-fixture/apply --transport agent-work --apply", + "cento parallel-delivery patch-swarm execute RUN_ID --fixture --json", + "cento parallel-delivery patch-swarm execute RUN_ID --live --budget-cap-usd 1 --max-budget-usd 1 --api-sandbox-candidates 1 --json", + "cento parallel-delivery patch-swarm integrate RUN_ID --dry-run --json", + "cento parallel-delivery patch-swarm integrate RUN_ID --apply --factory-run workspace/runs/factory/patch-swarm-RUN_ID --validate-each --json", + "cento parallel-delivery patch-swarm validate RUN_ID --json", + "cento parallel-delivery patch-swarm status RUN_ID --json", + "cento parallel-delivery patch-swarm status --run-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 --write-html --json", + "cento parallel-delivery patch-swarm e2e --candidate-target 30 --max-parallel-agents 3 --fixture --json", + "cento parallel-delivery patch-swarm e2e --candidate-target 25 --max-parallel-agents 5 --fixture --run-id fixture-console-25 --output-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 --json", + "cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --run-root workspace/runs/parallel-delivery/e2e-fixture --json", + "cento parallel-delivery self-improve run --json", + "cento parallel-delivery self-improve e2e --candidate-target 30 --max-parallel-agents 3 --budget-cap-usd 1 --max-budget-usd 1 --apply --validate-each --auto-merge-gate --json", + "cento parallel-delivery self-improve validate --json", + "cento parallel-delivery self-improve status --json", + "cento parallel-delivery self-improve install-cron --time 02:30" ], "outputs": [ - "workspace/runs/agent-work//operator-cutover-stop.log", - "workspace/runs/agent-work//operator-replacement-e2e.log" + "workspace/runs/parallel-delivery//implementation_manifest.json", + "workspace/runs/parallel-delivery//proreq_receipt.json", + "workspace/runs/parallel-delivery//execution_manifest.json", + "workspace/runs/parallel-delivery//validation_summary.json", + "workspace/runs/parallel-delivery//demo/demo_receipt.json", + "workspace/runs/parallel-delivery/train//train_manifest.json", + "workspace/runs/parallel-delivery/train//workset.json", + "workspace/runs/parallel-delivery/train//workset_check.json", + "workspace/runs/parallel-delivery/train//integration_queue.json", + "workspace/runs/parallel-delivery/train//train_receipt.json", + "workspace/runs/parallel-delivery/train//workset_execute_command.json", + "workspace/runs/parallel-delivery/train//workset_execute_result.json", + "workspace/runs/parallel-delivery/train//promotion_manifest.json", + "workspace/runs/parallel-delivery/train//promotion_decision.json", + "workspace/runs/parallel-delivery/train//promotion_decision.md", + "workspace/runs/parallel-delivery/train//factory_handoff.json", + "workspace/runs/factory/parallel-train-/factory-plan.json", + "workspace/runs/factory/parallel-train-/integration/apply-plan.json", + "workspace/runs/parallel-delivery/train//events.ndjson", + "workspace/runs/parallel-delivery/train//decision_report.md", + "workspace/runs/parallel-delivery/patch-swarm//patch_swarm_manifest.json", + "workspace/runs/parallel-delivery/patch-swarm//proreq_execution_manifest.json", + "workspace/runs/parallel-delivery/patch-swarm//candidate_index.json", + "workspace/runs/parallel-delivery/patch-swarm//dedupe_clusters.json", + "workspace/runs/parallel-delivery/patch-swarm//ranking.json", + "workspace/runs/parallel-delivery/patch-swarm//cost_ledger.json", + "workspace/runs/parallel-delivery/patch-swarm//usage_guard.json", + "workspace/runs/parallel-delivery/patch-swarm//provider_usage.jsonl", + "workspace/runs/parallel-delivery/patch-swarm//candidate_spend_ledger.jsonl", + "workspace/runs/parallel-delivery/patch-swarm//patch_swarm_receipt.json", + "workspace/runs/parallel-delivery/patch-swarm//integration_execution/integration_execution.json", + "workspace/runs/parallel-delivery/patch-swarm//safe_integrator_handoff.json", + "workspace/runs/parallel-delivery/patch-swarm//factory_promotion.json", + "workspace/runs/parallel-delivery/patch-swarm//validation_summary.json", + "workspace/runs/parallel-delivery/patch-swarm//ui_state.json", + "workspace/runs/parallel-delivery/patch-swarm//decision_report.md", + "workspace/runs/parallel-delivery/planner-fixture/split-plan.json", + "workspace/runs/parallel-delivery/planner-fixture/task-graph.json", + "workspace/runs/parallel-delivery/planner-fixture/task-contracts/task-0001.md", + "workspace/runs/parallel-delivery/lease-fixture/path-leases.json", + "workspace/runs/parallel-delivery/lease-fixture/lease-conflicts.json", + "workspace/runs/parallel-delivery/lease-fixture/lease-validation.json", + "workspace/runs/parallel-delivery/lease-fixture/workset-manifest.json", + "workspace/runs/parallel-delivery/lease-fixture/workset-compatibility.json", + "workspace/runs/parallel-delivery/proreq-fixture/prompt-bundle.json", + "workspace/runs/parallel-delivery/proreq-fixture/prompt-index.json", + "workspace/runs/parallel-delivery/proreq-fixture/prompts/prompt-0001-master.md", + "workspace/runs/parallel-delivery/proreq-fixture/prompts/prompt-0020-evidence.md", + "workspace/runs/parallel-delivery/proreq-fixture/temp-bridge.json", + "workspace/runs/parallel-delivery/codex-packets-fixture/codex-packet-bundle.json", + "workspace/runs/parallel-delivery/codex-packets-fixture/codex-packet-index.json", + "workspace/runs/parallel-delivery/codex-packets-fixture/packets/task-0001-codex-packet.md", + "workspace/runs/parallel-delivery/worker-status-fixture/worker-pool-plan.json", + "workspace/runs/parallel-delivery/worker-status-fixture/dry-run-dispatch.json", + "workspace/runs/parallel-delivery/worker-status-fixture/worker-queue-ledger.jsonl", + "workspace/runs/parallel-delivery/worker-status-fixture/worker-status.json", + "workspace/runs/parallel-delivery/worker-status-fixture/stale-workers.json", + "workspace/runs/parallel-delivery/worker-status-fixture/process-visibility.json", + "workspace/runs/parallel-delivery/worker-status-fixture/console-status.json", + "workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json", + "workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles/*.json", + "workspace/runs/parallel-delivery/patch-bundle-fixture/input/patches/*.diff", + "workspace/runs/parallel-delivery/patch-bundle-fixture/receipts/*.json", + "workspace/runs/parallel-delivery/patch-bundle-fixture/patch-bundle-report.json", + "workspace/runs/parallel-delivery/patch-bundle-fixture/patch-bundle-report.md", + "workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json", + "workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.rejected.json", + "workspace/runs/parallel-delivery/release-candidate-fixture/input/bundle-receipts/*.json", + "workspace/runs/parallel-delivery/release-candidate-fixture/input/patches/*.diff", + "workspace/runs/parallel-delivery/release-candidate-fixture/dry-run/apply-report.json", + "workspace/runs/parallel-delivery/release-candidate-fixture/apply/apply-report.json", + "workspace/runs/parallel-delivery/release-candidate-fixture/apply/release-candidate.json", + "workspace/runs/parallel-delivery/release-candidate-fixture/apply/release-notes.md", + "workspace/runs/parallel-delivery/release-candidate-fixture/apply/rollback-metadata.json", + "workspace/runs/parallel-delivery/e2e-fixture//validation-summary.json", + "workspace/runs/parallel-delivery/e2e-fixture//validation-report.md", + "workspace/runs/parallel-delivery/e2e-fixture//worker-packets/codex-packet-index.json", + "workspace/runs/parallel-delivery/e2e-fixture//integration/integration-receipt.json", + "workspace/runs/parallel-delivery/e2e-fixture//release-candidate/release-candidate.json", + "workspace/runs/parallel-delivery/console-fixture//console-data.json", + "workspace/runs/parallel-delivery/console-fixture//start-here.html", + "workspace/runs/parallel-delivery/console-fixture//link-check.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/patch-swarm/latest_ui_state.json", + "workspace/runs/ai-self-improvement-nightly//nightly_cycle_manifest.json", + "workspace/runs/ai-self-improvement-nightly//validation_gates.json", + "workspace/runs/ai-self-improvement-nightly//loop_metrics.json", + "workspace/runs/ai-self-improvement-nightly//promotion_recommendation.json", + "workspace/runs/ai-self-improvement-nightly//evidence_handoff.json", + "workspace/runs/ai-self-improvement-nightly//next_cycle_request.json", + "workspace/runs/ai-self-improvement-e2e//e2e_manifest.json", + "workspace/runs/ai-self-improvement-e2e//self_improve_source.json", + "workspace/runs/ai-self-improvement-e2e//patch_swarm_result.json", + "workspace/runs/ai-self-improvement-e2e//factory_promotion.json", + "workspace/runs/ai-self-improvement-e2e//safe_integrator_apply.json", + "workspace/runs/ai-self-improvement-e2e//auto_merge_gate.json", + "workspace/runs/ai-self-improvement-e2e//spend_summary.json", + "workspace/runs/ai-self-improvement-e2e//validation_summary.json", + "workspace/runs/ai-self-improvement-e2e//handoff.md", + "workspace/runs/ai-self-improvement-e2e/latest/e2e_manifest.json", + "workspace/runs/parallel-delivery/taskstream-fixture/input/split-plan.json", + "workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/story.json", + "workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/validation.json", + "workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/handoff.md", + "workspace/runs/parallel-delivery/taskstream-fixture/taskstream-handoff-report.json", + "workspace/runs/parallel-delivery/taskstream-fixture/taskstream-handoff-report.md", + "workspace/runs/parallel-delivery/taskstream-fixture/validation-summary.txt" + ], + "docs": [ + "docs/ai-self-improvement-autopilot.md", + "docs/ai-self-improvement-nightly.md", + "docs/parallel-integration-train.md", + "docs/parallel-ai-delivery-roadmap.md", + "docs/parallel-delivery/patch-swarm-artifacts.md", + "docs/parallel-delivery/patch-swarm-planner.md", + "docs/parallel-delivery/patch-swarm-leasing.md", + "docs/parallel-delivery/patch-swarm-proreq-prompts.md", + "docs/parallel-delivery/patch-swarm-codex-worker-packets.md", + "docs/parallel-delivery/patch-swarm-console.md", + "docs/parallel-delivery/patch-bundle-validation.md", + "docs/parallel-delivery/release-candidate-safe-apply.md", + "docs/parallel-delivery/patch-swarm-validation-e2e.md", + "docs/parallel-delivery/patch-swarm-taskstream.md", + "docs/parallel-delivery/patch-swarm-worker-status.md", + "docs/patch-swarm.md" ], "notes": [ - "Temp 1 installs the least-privilege migration cutover sudoers entry, stops the legacy board, validates the replacement backend, and updates the configured issue id (default #133 via CENTO_TEMP_ISSUE_ID).", - "Use `cento run temp 1 rollback` to start the legacy board again." + "Routes VP-level parallel delivery planning through existing Hard ProReq and Workset pipelines instead of inventing a new orchestration path.", + "The 100-call responsibility audit treats Parallel Delivery/Patch Swarm as the product facade, not the owner of duplicate queues, leases, patch bundles, task manifests, release-candidate formats, or dashboards.", + "Future Patch Swarm phases should run real low-risk pilots through existing Factory/Build/Workset/Agent Work surfaces with live dispatch off, then add only thin adapters for proven reuse breaks.", + "Default execution keeps live Pro disabled unless `--live-pro` is passed; image requests still follow the configured Hard ProReq image lane.", + "The train subcommands plan high-parallel Workset shards and a sequential dry-run integration queue without patch apply.", + "Train run accepts explicit --simulate or --workset-execute; the Workset mode calls `cento workset execute` and records command/result/receipt artifacts without passing --apply.", + "Train api-openai execution is explicit and requires both --budget-usd and --max-budget-usd.", + "Train promote converts completed Workset receipts into a Factory Safe Integrator handoff and apply plan; dry-run is the default.", + "Train e2e runs plan, Workset execute, train validation, and Factory promotion in one command.", + "Train simulation still requires explicit --dry-run for the separate integration step.", + "Patch Swarm plans ten ProReq execution lanes plus one dedicated serialized integrator for massively parallel patch candidate generation.", + "Patch Swarm providers normalize `codex exec`, Claude Code, and OpenAI structured patch proposals into candidate_patch.v1 receipts.", + "Patch Swarm fixture e2e can run small candidate targets for sandbox gates, ranks them, selects one winner per ProReq lane, and writes a Safe Integrator handoff without mutating the main worktree.", + "Patch Swarm live api-openai execution is fail-closed behind a live-enabled plan, --budget-cap-usd, --max-budget-usd, provider spend estimate, OPENAI_API_KEY, and a bounded api-patch-proposal sandbox candidate limit.", + "Patch Swarm integrate --apply promotes selected winners into Factory patch bundles, runs validate-fanout, and applies only through Factory/Safe Integrator worktrees.", + "Patch Swarm mirrors ui_state.json into Dev Pipeline Studio so the existing parallel execution UI can show candidates, provider mix, costs, validation, winners, and integration status.", + "Patch Swarm artifact schemas and fixture validation are documented in docs/parallel-delivery/patch-swarm-artifacts.md; the helper is scripts/parallel_delivery_artifacts.py and does not dispatch workers or apply patches.", + "Patch Swarm split creates bounded split-plan.json, task-graph.json, and task contract drafts through scripts/parallel_delivery_planner.py; no-model mode treats 100 as a cap rather than a target.", + "Patch Swarm leases create deterministic path-leases.json, conflict reports, dependency gates, Workset-compatible manifests, and operation validation through scripts/parallel_delivery_leases.py without applying patches.", + "Patch Swarm prompts generate local-only ChatGPT Pro copy/paste prompt bundles through scripts/parallel_delivery_prompts.py; they do not call live AI services by default.", + "Patch Swarm worker-packets emits local Codex-ready Markdown packets from split-plan, task-graph, and path-leases artifacts; it does not dispatch Codex or apply patches.", + "Patch Swarm worker-status plans bounded dry-run dispatch through scripts/parallel_delivery_worker_status.py, writes worker queue/status/process visibility artifacts, and does not launch external agents by default.", + "Patch Swarm patch-bundles collect local worker bundle manifests, validate diffs against authoritative leases through scripts/parallel_delivery_patch_bundles.py, and write receipts/reports without applying patches.", + "Parallel Delivery release-candidate create reads accepted integration receipts, validates accepted bundle receipts and patch hashes, dry-runs by default, applies only in isolated target worktrees when --mode apply is explicit, and writes apply receipts, rollback metadata, release notes, and release-candidate.json.", + "Patch Swarm validation e2e composes split planning, path leases, worker packets, simulated fixture patch bundles, deterministic validation, dry-run integration, and fixture release-candidate evidence through scripts/parallel_delivery_validation_e2e.py.", + "The self-improvement loop runs four sequential Hard ProReq planning passes, validates artifacts, recommends promotion, writes the next-cycle request, and stops before implementation dispatch.", + "Self-improvement artifacts mirror to workspace/runs/ai-self-improvement-nightly/latest/.", + "Self-improvement e2e connects latest next_cycle_request.json to Patch Swarm, Factory validate-fanout, bounded Safe Integrator apply, and factory merge --auto-merge-main --dry-run without pushing main.", + "A gpt-image-2 403 is recorded as nonblocking image evidence and does not fail backend planning.", + "The demo uses ten fixture workers, max_parallel 10, sequential dry-run integration, and zero repository mutations.", + "Validation requires all Hard ProReq passes to complete, every generated workset to pass `cento workset check`, and the demo receipt to pass.", + "Patch Swarm taskstream emits existing agent-work story.json and validation.json manifests from split-plan artifacts; dry-run manifest generation is the default.", + "Patch Swarm taskstream apply refuses live Taskstream creation unless --apply is present and routes live work through cento agent-work rather than direct database writes." ] } ] diff --git a/docs/agent-work-live-dispatch-incident.md b/docs/agent-work-live-dispatch-incident.md new file mode 100644 index 0000000..90556f2 --- /dev/null +++ b/docs/agent-work-live-dispatch-incident.md @@ -0,0 +1,76 @@ +# Agent Work Live Dispatch Incident + +Use this runbook when `walk-autopilot` or `agent-pool-kick` reports a live worker launch failure. The default response is incident handling with bounded repair and retry, not switching the loop back to proof-only work. + +## Incident Class + +The common class is `missing_canonical_manifest`: + +- `agent-pool-kick --dry-run` shows queued live candidates. +- `agent-work dispatch` blocks preflight with a missing canonical `story.json`. +- The affected issue may be moved to `Blocked` by the failed dispatch before repair runs. + +Other classes are `dispatch_preflight_blocked`, `agent_pool_live_timeout`, or a reason from `agent-pool-kick` such as `agent_pool_runtime_missing`. + +## Immediate Response + +Run the repair with preflight still enabled: + +```bash +./scripts/cento.sh agent-pool-kick \ + --repair-missing-manifests \ + --repair-apply \ + --repair-lanes all \ + --repair-limit 3 \ + --max-launch 0 \ + --dry-run +``` + +If the failure already changed a candidate to `Blocked`, force the specific issue id: + +```bash +./scripts/cento.sh agent-pool-kick \ + --repair-missing-manifests \ + --repair-apply \ + --repair-lanes all \ + --repair-issue ISSUE_ID \ + --repair-limit 3 \ + --max-launch 0 \ + --dry-run +``` + +Then retry the live launch with the same bound: + +```bash +./scripts/cento.sh agent-pool-kick --max-launch 3 +``` + +Do not add `--skip-preflight`; repair the manifest contract instead. + +## Walk Autopilot Behavior + +When live dispatch fails, `walk-autopilot` writes an incident bundle under: + +```text +workspace/runs/walk-autopilot//incidents/ +``` + +Each bundle contains: + +- `incident.json` with classification, candidate issue ids, manifest gaps, and resolution status. +- `attempts.jsonl` with the original live launch, repair command, post-repair dry-run, retry, and recovery-plan command when needed. +- `notes.md` for operator handoff. + +If the same unresolved class repeats in consecutive loops, the run creates or records a guarded self-improvement follow-up instead of silently cycling. + +## Validation + +After repair, validate the restored contract before broad dispatch: + +```bash +python3 -m json.tool data/tools.json +python3 -m pytest tests/test_agent_pool_kick.py tests/test_walk_autopilot.py +./scripts/cento.sh agent-pool-kick --max-launch 3 --dry-run +``` + +The incident is recovered when the bounded live retry exits `0` or a new, more specific blocker is recorded in the incident bundle. diff --git a/docs/agent-work-runtimes.md b/docs/agent-work-runtimes.md index 99369d5..c933e34 100644 --- a/docs/agent-work-runtimes.md +++ b/docs/agent-work-runtimes.md @@ -5,19 +5,26 @@ Cento agent-work dispatch supports weighted local agent runtimes. ## Registered Runtimes Runtime registry: `data/agent-runtimes.json` +Local command runtime profiles: `.cento/runtimes.yaml` - `codex` - Provider: OpenAI - Default model: `gpt-5.3-codex-spark` - - Weight: `75` - - Role: preferred majority runtime + - Weight: controlled by `cento compute-policy` + - Role: preferred when Codex limit/subscription capacity is available - `claude-code` - Provider: Anthropic - Default model: `claude-sonnet-4-6` - Plan: personal Pro - - Weight: `25` - - Role: secondary runtime for roughly 20-30% of automatic task dispatches + - Weight: controlled by `cento compute-policy` + - Role: fallback or partial-share runtime for automatic task dispatches + +- `claude-code-fast` + - Surface: local command runtime profile for Workset/Build-style isolated worktrees + - Command: `claude -p --output-format text` + - Prompt delivery: stdin from the generated builder prompt + - Role: Patch Swarm candidate provider adapter compatible with `candidate_patch.v1` receipts ## Routing @@ -27,10 +34,17 @@ Automatic routing is deterministic and weighted by issue id, role, and package. python3 scripts/agent_work.py runtimes --sample 1000 ``` -Expected result should stay near: +Expected result for the default `codex-first` policy: + +- Codex: about `85%` +- Claude Code: about `15%` + +To change this mix: -- Codex: about 70-80% -- Claude Code: about 20-30% +```bash +cento compute-policy set --codex 70 --claude 30 --openai-api 0 --json +cento agent-work runtimes --sample 1000 --json +``` ## Dispatch Examples @@ -55,18 +69,25 @@ python3 scripts/agent_work.py dispatch ISSUE_ID --runtime codex --dry-run Spark worker pool planning: ```bash -python3 scripts/agent_work.py dispatch-pool --limit 3 +cento agent-pool-kick --dry-run --max-launch 3 ``` -`dispatch-pool` defaults to `runtime=codex` and `model=gpt-5.3-codex-spark`. It prints planned dispatch commands without mutating issues. Add `--execute` only when the operator wants those cheap workers started. +`agent-pool-kick` honors `CENTO_AGENT_RUNTIME` when set. Otherwise it uses Agent Work `auto` routing and the current compute-policy weights. ## Overrides - `CENTO_AGENT_RUNTIME=claude-code` forces the runtime when dispatch uses `--runtime auto`. +- `CENTO_AGENT_RUNTIME=codex` forces Codex. - `CENTO_AGENT_RUNTIME_CONFIG=/path/to/agent-runtimes.json` uses a different runtime registry. - `CENTO_CLAUDE_BIN=/path/to/claude` overrides the Claude Code binary. - `CENTO_CODEX_BIN=/path/to/codex` overrides the Codex binary. ## Cost Policy -Codex remains the primary runtime. Claude Code is registered at 25% because its budget is materially lower. +Use `cento compute-policy` to prefer available agent subscription/limit before metered API calls: + +```bash +cento compute-policy preset codex-first --json +``` + +Explicit `api-openai` commands remain explicit API spend. diff --git a/docs/agent-work.md b/docs/agent-work.md index 5fcde1a..3755ef4 100644 --- a/docs/agent-work.md +++ b/docs/agent-work.md @@ -170,34 +170,26 @@ cento agent-work dispatch 123 --node linux --agent codex --model gpt-5.3-codex-s ## Spark Worker Pool -Use `dispatch-pool` to keep cheap Spark/Codex workers busy without interrupting the main operator session. It is plan-only by default and does not start agents unless `--execute` is passed. +Use `agent-pool-kick` to keep cheap Spark/Codex workers busy without interrupting the main operator session. It is plan-only by default (`--dry-run`) and does not start agents unless `--dry-run` is omitted. -Plan the next three queued items: +Plan the next pool cycle (dry run): ```bash -cento agent-work dispatch-pool --limit 3 -``` - -Plan queued work for one package: - -```bash -cento agent-work dispatch-pool --package spark-docs-evidence-lane --limit 2 +cento agent-pool-kick --dry-run ``` -Start the planned Spark workers explicitly: +Plan queued work for one package (dry run): ```bash -cento agent-work dispatch-pool --limit 2 --runtime codex --model gpt-5.3-codex-spark --execute +cento agent-pool-kick --package spark-docs-evidence-lane --dry-run ``` -For automation and dashboards: +Launch Spark workers (live, up to two): ```bash -cento agent-work dispatch-pool --limit 5 --json +cento agent-pool-kick --max-launch 2 --runtime codex --model gpt-5.3-codex-spark ``` -The JSON output includes `diagnostics`, including `zero_launch_reason`, so a zero-worker result explains whether the cause was no matching status, filters, companion-node exclusion, non-task epics, or `--limit 0`. - Check what is actually running: ```bash @@ -248,6 +240,8 @@ cento agent-pool-kick --max-launch 3 cento agent-pool-kick --max-launch 3 --model gpt-5.3-codex-spark ``` +If live dispatch fails on missing canonical manifests, treat it as an incident and repair the contract instead of disabling workers. See [Agent Work Live Dispatch Incident](agent-work-live-dispatch-incident.md). + It writes the latest summary to: ```text diff --git a/docs/ai-cento-native-execution-plan.md b/docs/ai-cento-native-execution-plan.md new file mode 100644 index 0000000..3f4a560 --- /dev/null +++ b/docs/ai-cento-native-execution-plan.md @@ -0,0 +1,2492 @@ +# Cento AI Native Execution Plan + +Generated: 2026-05-05T05:13:19Z + +Source research: `docs/ai-cento-native-rework-research.md`. + +Run directory: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z`. + +This document is the canonical execution plan produced by the controlled Spark coordination run. It validates the older research plan against the live Cento repo, preserves current/proposed/outdated distinctions, expands the architecture into implementation contracts, and defines how future Spark lanes should execute and validate the work. + +## Integration Summary + +- Execution mode: controlled live Spark drafting with `gpt-5.3-codex-spark` section workers plus coordinator integration. +- Canonical output: `docs/ai-cento-native-execution-plan.md`. +- Worker outputs: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/*.md`. +- Taskstream records: coordinator `1000178`; lane records `1000179`, `1000180`, `1000181`, `1000182`, `1000183`, `1000184`, `1000185`, `1000187`. +- Current-state facts are valid only as of 2026-05-05 and must be refreshed with the commands in the validation checklist before implementation decisions. + +## Important Current Corrections + +- The original research doc is useful but stale in several places: tool count is now 50, latest hard-proreq run is `hard-proreq-task-hard-proreq-project-20260505T050416185358Z`, and stored hard-proreq run history is larger than the older observation. +- `parallel-delivery` exists and the latest status reports `completed`, `validation: passed`, `demo: completed`, and `pass_count: 12`. +- `cento runtime list --json` currently reports `codex-fast`, `fixture-valid`, and `python-fixture` as passing. +- MCP remains narrow: context/platform/cluster/bridge/agent-work/story tools exist, while Dev Pipeline/build/workset/factory/runtime/scan/evidence MCP tools are proposed future work. +- `agent-work` does not expose a pool-dispatch subcommand; use `agent-work dispatch` for direct issue dispatch and `agent-pool-kick --dry-run` for pool planning. `agent-pool-kick --json` is also not accepted, although dry-run output is JSON-shaped. +- Hard-proreq Pro planning is still gated/fallback unless `CENTO_HARD_PROREQ_DISPATCH_PRO=1` and credentials are configured; do not describe deterministic fallback artifacts as live Pro output. + +## Section Index + +- Validation Matrix: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/validation_matrix.md` (10423 bytes), Taskstream `1000179` +- Current State: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/current_state.md` (14540 bytes), Taskstream `1000180` +- Target Architecture: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/target_architecture.md` (18178 bytes), Taskstream `1000181` +- Proposed Interfaces: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/interfaces.md` (16226 bytes), Taskstream `1000182` +- Dev Pipeline Gaps And Fixes: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/dev_pipeline_gaps.md` (7579 bytes), Taskstream `1000184` +- Skills And Runtime Policy: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/skills_runtime.md` (15962 bytes), Taskstream `1000185` +- Spark Coordination Runbook: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/spark_coordination.md` (6297 bytes), Taskstream `1000183` +- Validator Checklist: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/validator_review.md` (9955 bytes), Taskstream `1000187` + +--- + +## Validation Matrix + +_Integrated from `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/validation_matrix.md`._ + +# Validation Matrix: `docs/ai-cento-native-rework-research.md` (as of 2026-05-05) + +Scope: validate claims in the research document against the live Cento repo at `/home/alice/projects/cento`. + +## Status Legend + +- `true` = verified against current state +- `partially true` = validated core idea but implementation/details differ +- `outdated` = claim is no longer current (stale counts, dates, paths, etc.) +- `false` = explicit claim is contradicted by current state +- `unknown` = future-state/recommendation with insufficient runtime verification + +## Matrix + +| ID | Claim | Status | Evidence | +| --- | --- | --- | --- | +| C01 | `cento gather-context --no-remote` reports 45 registered tools. | `outdated` | `cento gather-context --no-remote` now reports `total tools: 50`, `Linux tools: 47`, `both: ...`. `data/tools.json` also contains `len 50` tool entries. | +| C02 | `cento tools` / `cento gather-context` indicate `data/tools.json` + `cento tools` + `cento platforms` + `cento docs` are source-of-truth. | `true` | `cento gather-context --no-remote` prints source-of-truth note; `data/tools.json` is present and loaded; docs list these command families. | +| C03 | Runtime list contains `codex-fast`, `fixture-valid`, `python-fixture` with passing validation. | `true` | `cento runtime list --json` returns exactly these three profiles, each `status: "passed"` with executables and limits. | +| C04 | MCP surface is narrow: agent-work/cluster/bridge/context/story only; no dev pipeline/build/workset/factory/runtime/scan/evidence start tools. | `true` | `CENTO_MCP_READ_ONLY=1 python3 scripts/cento_mcp_server.py --list-tools` returns only 11 tool entries (`cento_agent_work_*`, `cento_cluster_status`, `cento_bridge_mesh_status`, `cento_context`, `cento_platforms`, `cento_story_manifest_*`). | +| C05 | MCP README/docs reflect the same narrow scope. | `true` | `docs/cento-mcp-server.md` lists the same 11 tool families and labels MCP as a small allowlist wrapper without full CLI exposure. | +| C06 | MCP tooling includes command entry points for `dev-pipeline state`, `scan`, `runtime` and `evidence` checks. | `false` | No such tool names appear in MCP list output (only story/agent-work/cluster/context/platform). | +| C07 | Dev Pipeline hard-proreq latest run in repo is `hard-proreq-task-hard-proreq-project-20260504T065328911797Z`. | `outdated` | Latest hard-proreq directory under `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest` points to `hard-proreq-task-hard-proreq-project-20260505T050416185358Z` (from `execution_run.run_id`). | +| C08 | Latest hard-proreq execution status is `completed` with source `cento-hard-proreq-pro`. | `true` | `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/execution_run.json` has `status: "completed"` and `source: "cento-hard-proreq-pro"`. | +| C09 | Hard-proreq execution exposes 4-stage UI cards backed by 6 stage records and 9 steps. | `partially true` | `execution_run.json` has `6` stages and `9` steps. Stage IDs: `input`, `repo`, `blueprint`, `factory`, `validation`, `handoff`. | +| C10 | Latest run artifact count was `23` in API with `13` existing artifacts in payload summary. | `outdated` | Current `execution_run.json` has `20` artifacts. | +| C11 | Latest hard-proreq run has artifacts for the same end-to-end chain (intake, context, screenshot lane, schema, plan, workstreams, evidence). | `true` | `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest` contains: `operator_intake.json`, `mini_cento_context.json`, `ui_screenshot_request.json`, `existing_ui_reference*.png`, `image_generation*.json`, `pro_output_schema.json`, `pro_backend_*.json`, `backend_work_manifest.json`, `integration_plan.json`, `validation_plan.json`, `hard_proreq_evidence.json`, etc. | +| C12 | `execution_run.json` carries proof/validation status fields to drive UI trust. | `partially true` | `execution_run.json` has keys `proof` and `validation`, currently both `None`; so the payload is explicit but no status is populated. | +| C13 | Hard-proreq evidence artifact is `completed`. | `true` | `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/hard_proreq_evidence.json` contains `"status": "completed"`. | +| C14 | Pro backend run is gated and falls back when `CENTO_HARD_PROREQ_DISPATCH_PRO` is not enabled. | `true` | `pro_backend_plan.json` summary: `"GPT pro request is schema-ready; backend work uses deterministic fallback until CENTO_HARD_PROREQ_DISPATCH_PRO=1 is enabled."`; `pro_backend_error.json` reason: `"Pro API dispatch is gated..."`. | +| C15 | Hard-proreq validation should show template validators as `passed, passed, muted` in execution summary. | `partially true` | In latest run, `validation_plan.json` has `validators: []` and `status: null` for top-level validation state. | +| C16 | Dev Pipeline hard-proreq history in the live run view is around 9 runs. | `outdated` | `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq` currently contains `37` non-`latest` run directories; UI claim of 9 is stale against stored run set. | +| C17 | `cento scan --query "agent-work" --no-open` produced 1514 scanned files, 223 matched files, 1286 matches. | `outdated` | Latest scan summary file `workspace/runs/scan-onepager/latest/summary.json` now reports `matched_files: 616`, `total_matches: 3320`. | +| C18 | `cento agent-work` exposes a pool-dispatch subcommand in help and runtime flow. | `false` | `cento agent-work --help` includes `dispatch` but no pool-dispatch subcommand. | +| C19 | `cento-agent-work docs` are aligned with current CLI (the removed pool subcommand is documented as usable). | `outdated` | Stale examples with `--execute` and `--json` flags appeared in `docs/agent-work.md` and `docs/agent-work-runtimes.md`, but those subcommands do not exist in help output; references have been removed. | +| C20 | `cento agent-pool-kick --help` exposes `--json` for machine output mode. | `false` | `cento agent-pool-kick --help` flags: `--builder-target`, `--validator-target`, `--small-target`, `--coordinator-target`, `--max-launch`, `--dry-run` only; no `--json`. | +| C21 | `agent-pool-kick` does not return machine-readable output by default. | `false` | `cento agent-pool-kick --dry-run` returns full JSON with fields like `generated_at`, `active_counts`, `targets`, `launched`, etc., even without any `--json` flag. | +| C22 | Docs/mcp claims match the CLI runtime around Spark lane planning. | `partially true` | The documented Spark workflow is partially present via `agent-pool-kick --dry-run`, but stale pool planning references in docs (removed subcommand and `--json` flag) are incorrect per actual help. | +| C23 | `data/tools.json` includes build/factory/workset/scan/runtime as registered tool families. | `true` | `python3 - <<'PY'` read of `data/tools.json` confirms 50 tools including `build`, `workset`, `factory`, `runtime`, `scan`, etc.; CLI `cento tools` output also lists these families. | +| C24 | `cento docs build` and `cento docs factory` resolve correctly and are discoverable. | `true` | `cento docs build` and `cento docs factory` execute successfully (exit 0) and print canonical command examples; this can be reproduced from the recorded command output. | +| C25 | `scripts/agent_work_app.py` is split (small bootstrap + route modules). | `false` | `rg` hits in `scripts/agent_work_app.py` show constants, schema handlers, task/DB sync, and dev pipeline execution/evidence logic in one file. | +| C26 | `scripts/cento_openai_worker.py` defines schema-backed outputs and validation gates, and does not mutate repository state. | `partially true` | `rg` confirms schemas for `patch_proposal.v1`, `validation_review.v1`, `hard_proreq_plan.v1`, `workset_plan.v1`; the repository currently also has non-worker integration paths, but the file itself is schema-driven and no direct mutation was asserted from schema constants alone. | +| C27 | `parallel-delivery` run for the run directory in question is complete with passing validation and demo completed. | `true` | `cento parallel-delivery status --json` currently reports `status: "completed"`, `pass_count: 12`, `validation: "passed"`, `demo: "completed"`, `run_dir: workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z`. | +| C28 | `parallel-delivery` evidence/implementation is validated by passing tests. | `partially true` | Existing tests around hard-proreq delivery were run: `python3 -m pytest tests/test_dev_pipeline_delivery.py -q` reports `15 passed`. No dedicated `tests/test_parallel_delivery.py` exists in this repo snapshot. | +| C29 | The latest hard-proreq plan includes exactly the documented 10 workstreams. | `true` | `pro_backend_plan.json` from latest hard-proreq run has `len(backend_workstreams) == 10`. | +| C30 | `cento-native` skills are already comprehensive for routing, run envelopes, and evidence handoff. | `partially true` | Installed skills under `/home/alice/.codex/skills` are `cento-native`, `cento-requirements-manifest`, `navigate-skills`, `ui-verify-and-report` only. | +| C31 | Proposed `cento-ai-run`, `cento-validator`, `cento-evidence-handoff` already exist. | `false` | Only currently available Cento skills are as listed above; those proposed names are not present in `/home/alice/.codex/skills` and no dedicated command surfaces are present. | +| C32 | MCP read/write extension should include `cento_runtime_list`, `cento_dev_pipeline_*`, `cento_evidence_check` as part of rework. | `unknown` | This is a proposed target in the research; implementation is not currently present, so it is not yet verifiable as true/false in current runtime. | +| C33 | UI proof and validation currently can still be considered fully aligned to source-of-truth receipts. | `partially true` | Evidence mismatch risk exists: top-level `execution_run.proof` is `None` while artifacts + `hard_proreq_evidence.json` exist, which supports the documented concern in findings. | +| C34 | Latest hard-proreq evidence artifacts are fully integrated into execution_run artifact list. | `true` | `execution_run.json` artifact list explicitly includes `execution/hard-proreq/latest/...` paths and both `validation_plan.json` and `hard_proreq_evidence.json`. | +| C35 | Scan and hard-proreq tooling are still discoverable via docs. | `true` | `docs/cento-mcp-server.md`, `docs/agent-work.md`, `scripts/dev_pipeline_hard_proreq.py` and the command outputs above confirm scan + hard-proreq paths remain present and operational. | + +--- + +## Current State + +_Integrated from `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/current_state.md`._ + +# Current-State Map (Cento) — 2026-05-05 + +Scope: `/home/alice/projects/cento` runtime and docs as observed on **2026-05-05**. + +## 0) Anchors and evidence collection + +- Canonical state refresh: + - `cd /home/alice/projects/cento && python3 scripts/gather_context.py --no-remote` + - `cd /home/alice/projects/cento && python3 scripts/cento_runtime.py list --json` + - `cd /home/alice/projects/cento && python3 scripts/agent_work.py list --json` + - `cd /home/alice/projects/cento && python3 scripts/agent_work.py recovery-plan --json` + - `cd /home/alice/projects/cento && python3 scripts/agent_pool_kick.py --dry-run` + - `cd /home/alice/projects/cento && python3 scripts/cento_mcp_server.py --list-tools` + - `cd /home/alice/projects/cento && python3 scripts/parallel_delivery.py status --json` +- Rundir for this execution slice: + - `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z` + - `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/current_state.md` (this file) + +## 1) Registered tools + +### 1.1 Tool registry state + +- Source-of-truth JSON: `data/tools.json` (top-level object `tools`) + - `jq '.tools | length' data/tools.json` → `50` tools. +- `cento gather-context --no-remote` confirms: + - total tools: `50` + - linux tools: `47` + - macOS tools: `37` + - both: `37` + +### 1.2 Registered IDs (50 total) + +- `agent-pool-kick` +- `agent-processes` +- `agent-work` +- `agent-work-hygiene` +- `audio-quick-connect` +- `batch-exec` +- `bluetooth-audio-doctor` +- `bridge` +- `build` +- `burp` +- `cento-cli` +- `cento-mcp` +- `cluster` +- `crm` +- `daily` +- `dashboard` +- `demo-evidence` +- `discord` +- `display-layout-fix` +- `factory` +- `gather-context` +- `i3reorg` +- `incident` +- `install-linux` +- `install-macos` +- `kitty-theme-manager` +- `mcp` +- `mobile` +- `mozilla-vpn` +- `network-tui` +- `notify` +- `object-storage` +- `opencode` +- `parallel-delivery` +- `platform-report` +- `preset` +- `project-scaffold` +- `quick-help` +- `quick-help-fzf` +- `rd` +- `repo-snapshot` +- `runtime` +- `scan` +- `search-report` +- `system-inventory` +- `temp` +- `tool-index` +- `tui` +- `wallpaper-manager` +- `workset` + +### 1.3 Platform-availability quick map + +- linux-only (subset): `audio-quick-connect`, `bluetooth-audio-doctor`, `burp`, `dashboard`, `i3reorg`, `preset`, `quick-help`, `discord`, `rd`, `wallpaper-manager`, `install-linux` +- macOS-only: `incident`, `install-macos`, `mobile`, plus mobile tooling lane +- both: `cento-cli`, `agent-work`, `build`, `factory`, `runtime`, `workset`, `parallel-delivery`, `cento-mcp`, etc. + +### 1.4 Relevant docs for tool contract + +- `data/tools.json` (registry payload) +- `data/cento-cli.json` (root CLI metadata) +- `docs/tool-index.md` (command index) +- `docs/cento-cli.md` +- `docs/agent-work.md` +- `docs/agent-work-runtimes.md` +- `docs/cento-mcp-server.md` +- `docs/factory.md` +- `docs/cento-workset.md` +- `docs/cento-build.md` +- `docs/dev-pipeline-run-contracts.md` + +### 1.5 Relevant schema definitions + +- JSON schemas: + - `docs/schemas/cento.build.v1.json` + - `docs/schemas/cento.validation_receipt.v1.json` + - `docs/schemas/cento.apply_receipt.v1.json` + - `docs/schemas/cento.integration_receipt.v1.json` + - `docs/schemas/cento.worker_artifact.v1.json` + - `docs/schemas/cento.patch_bundle.v1.json` + - `docs/schemas/cento.taskstream_evidence.v1.json` +- Runtime/API schema contracts (embedded constants rather than standalone JSON files): + - `scripts/cento_openai_worker.py` + - `scripts/cento_workset.py` + - `scripts/agent_work_app.py` + - `scripts/dev_pipeline_hard_proreq.py` + +## 2) Runtime profiles and AI routing + +### 2.1 Command/runtime layer + +Command and live results: +- `python3 scripts/cento_runtime.py list --json` +- `python3 scripts/agent_work.py runtimes --json` +- `.cento/runtimes.yaml` +- `.cento/modes.yaml` +- `data/agent-runtimes.json` +- `.cento/api_workers.yaml` + +### 2.2 Registered runtime profiles (live) + +`python3 scripts/cento_runtime.py list --json` currently returns: +- `codex-fast` (type `command`) — status `passed` +- `fixture-valid` (type `fixture`) — status `passed` +- `python-fixture` (type `command`) — status `passed` + +### 2.3 Runtime contract files + +- `.cento/runtimes.yaml` + - profiles: `codex-fast`, `fixture-valid`, `python-fixture` + - both command profiles allowlist `PATH`, `HOME`, etc. and enforce patch/file budget caps +- `.cento/modes.yaml` + - `fast`, `standard`, `thorough` mode semantics +- `.cento/api_workers.yaml` + - request model profiles: `api-planner`, `api-section-worker`, `api-reviewer`, `api-mini-integrator`, `api-proreq-planner` +- `data/agent-runtimes.json` / `agent_work.py runtimes --json` + - `codex` `weight: 0`, `budget_note`: temporarily disabled for dispatch + - `claude-code` `weight: 100`, `preferred: true` + - sample counts from output: `claude-code: 100` for a 100-size routing sample + +## 3) MCP surface and current gaps + +### 3.1 MCP config and tool listing + +- MCP config file: `.mcp.json` + - servers present: `cento`, `filesystem`, `fetch`, `github` +- `python3 scripts/cento_mcp_server.py --list-tools` returns 11 tools: + - `cento_agent_work_list` + - `cento_agent_work_show` + - `cento_agent_work_create` + - `cento_agent_work_update` + - `cento_agent_work_claim` + - `cento_agent_work_validate_run` + - `cento_agent_work_handoff` + - `cento_context` + - `cento_platforms` + - `cento_cluster_status` + - `cento_bridge_mesh_status` + - `cento_story_manifest_validate` + - `cento_story_manifest_render_hub` +- Read-only mode exists via `CENTO_MCP_READ_ONLY` in server docs and wrapper logic. + +### 3.2 MCP vs CLI surface gap (confirmed) + +- MCP remains narrow and does not expose: + - `workset` + - `build` + - `factory` + - `scan` + - `runtime` + - `parallel-delivery` + - `agent-pool-kick` (no direct wrapper) + - `dev_pipeline_hard_proreq` and hard-proreq pipeline controls +- This is already documented as a deliberate narrow allowlist in `docs/cento-mcp-server.md`. + +## 4) Dev Pipeline state (hard-proreq, live artifacts) + +### 4.1 Run selection and status + +- Latest hard-proreq run ID (known): `hard-proreq-task-hard-proreq-project-20260505T050416185358Z` +- Execution run object: + - `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/execution_run.json` + - status: `completed` + - pipeline: `hard-proreq-task-hard-proreq-project` + - source: `cento-hard-proreq-pro` + - run_id: `hard-proreq-task-hard-proreq-project-20260505T050416185358Z` + - stages: `input,repo,blueprint,factory,validation,handoff` + - artifact count: `20` + +### 4.2 Artifact snapshots + +- Canonical latest run path: + - `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/` +- Latest run mirror path: + - `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/hard-proreq-task-hard-proreq-project-20260505T050416185358Z/` +- Representative files present under latest: + - `operator_intake.json` + - `mini_cento_context.json` + - `ui_screenshot_request.json` + - `pro_backend_request.json` + - `image_generation_request.json` + - `image_generation_response.json` + - `pro_output_schema.json` + - `parallel_patch_workset.json` + - `story_index.json` + - `stories/*.json` + `stories/*.validation.json` + - `backend_work_manifest.json` + - `manifest_integration_policy.json` + - `integration_plan.json` + - `validation_plan.json` + - `hard_proreq_evidence.json` + - screenshot files: `existing_ui_reference.png`, `existing_ui_reference_square.png`, `generated_integrator_screenshot.png` + +### 4.3 Command references for pipeline contracts + +- `cd /home/alice/projects/cento && sed -n ... docs/dev-pipeline-run-contracts.md` +- `cd /home/alice/projects/cento && python3 scripts/dev_pipeline_hard_proreq.py --help` +- `cd /home/alice/projects/cento && python3 scripts/dev_pipeline_hard_proreq.py all` +- `cd /home/alice/projects/cento && python3 scripts/agent_work_app.py` (dev-pipeline route wiring lives here) + +### 4.4 Recent hard-proreq history + +- `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/` currently contains `37` historical run folders (plus `latest`), showing continuous execution cadence across previous timestamps. + +## 5) Parallel-delivery lane + +- Status command: `cd /home/alice/projects/cento && python3 scripts/parallel_delivery.py status --json` +- Current status (as of run): + - `run_dir`: `workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z` + - `status`: `completed` + - `pass_count`: `12` + - `validation`: `passed` + - `demo`: `completed` +- Validation summary path: + - `workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z/validation_summary.json` + - checks include `proreq.pass_count 12/12`, `proreq.completed 12/12`, `proreq.workset_checks 12/12`, `demo.status completed` +- Execution manifest path: + - `workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z/execution_manifest.json` +- Implementation manifest path: + - `workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z/implementation_manifest.json` + - target: `workers=10` + - integrator policy: `only-if-needed`, reviewer profile `api-mini-integrator` +- Receipt paths: + - `workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z/proreq_receipt.json` + - `workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z/proreq_receipt.partial.json` +- Demo paths: + - `workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z/demo/demo_receipt.json` + - `workspace/runs/parallel-delivery/vp-e2e-20260505T0230Z/demo/workset.json` + - `.cento/worksets/parallel_delivery_demo_vp_e2e_20260505t0230z_20260505023500154341/workset_receipt.json` + +## 6) Build/workset/factory surfaces + +### 6.1 Build + +- Script: `scripts/cento_build.py` +- Command surfaces: `docs/cento-build.md`, `cento build ...` +- Key outputs under `.cento/builds//`: + - `manifest.json` + - `workers//...` (`worker_artifact.json`, `patch_bundle.json`, `patch.diff`, `handoff.md`) + - `integration_receipt.json` + - `validation_receipt.json` + - `apply_receipt.json` + - `taskstream_evidence.json` + - `events.ndjson` + +### 6.2 Workset + +- Script: `scripts/cento_workset.py` +- Command surfaces: `docs/cento-workset.md`, `cento workset ...` +- Live workset run directories: + - `.cento/worksets//` + - Includes `workset.json`, `leases.json`, `workset_receipt.json`, `workset_evidence.json`, `events.ndjson`, `workers/*/` +- Build artifacts produced for workset tasks: + - `.cento/builds/workset__/...` (manifest, workers, integration/validation/apply receipts) + +### 6.3 Factory + +- Script: `scripts/factory.py` plus integrations under `scripts/factory_*` +- Command surfaces: `docs/factory.md`, `docs/factory-integration.md`, `docs/factory-autopilot.md` +- Run state under `workspace/runs/factory//` +- Contract outputs include: + - `intake.json` + - `constraints.json` + - `context-pack.json` + - `factory-plan.json` + - `tasks//story.json` + - `tasks//validation.json` + - `tasks//dispatch.json` + - `dispatch-plan.json` + - `integration/*` + - `release-packet.md`, `project-delivery.md`, `summary.md` +- Factory also depends on runtime adapters under `factory/runtime/*` + +## 7) Agent-work board and dispatch state + +### 7.1 Live board summary + +- `cd /home/alice/projects/cento && python3 scripts/agent_work.py list --json | jq`: + - total issues: `30` + - status: `Blocked 2`, `Queued 25`, `Running 2`, `Validating 1` + - role: `builder 24`, `validator 3`, `coordinator 3` + - package: `agent-ops 14`, `default 12`, `kanji-a-day-watch-mvp 2`, `kanji-a-day 1`, `redmine-retirement-e2e-v1 1` + - source: `local 16`, `taskstream 14` + - nodes: `linux 13`, `macos 2`, blank `15` (legacy/unknown node fields) +- `cd /home/alice/projects/cento && python3 scripts/agent_work.py runs --json --active --no-untracked` + - `runs: []`, `count: 0` (tracked active=0 at snapshot time) + +### 7.2 Recovery and stale-state + +- `cd /home/alice/projects/cento && python3 scripts/agent_work.py recovery-plan --json` + - board after snapshot: `Queued 27`, `Running 2`, `Validating 1`, `Blocked 2` +- Reported manual/interactive sessions: 4 untracked interactive `node=linux` sessions. +- Reported stale runs: 5 (`stale_no_process`) from mixed agent roles with prior codex/claude sessions. +- Candidate safe follow-ups were generated for internal artifact gaps: + - issue `1000175` + - issue `1000173` + +### 7.3 Pool dispatch planning + +- `cd /home/alice/projects/cento && python3 scripts/agent_pool_kick.py --dry-run` + - active counts: builder/validator/small/coordinator = `0/0/0/0` + - targets: builder `4`, validator `3`, small `3`, coordinator `1` + - run mode: `dry_run=true` + - planned launches included queued issues in validator and builder lanes (8 total planned records in sample) + - no launches actually started because dry-run mode +- `cd /home/alice/projects/cento && python3 scripts/agent_pool_kick.py --help` + - flags: `--builder-target`, `--validator-target`, `--small-target`, `--coordinator-target`, `--max-launch`, `--dry-run` + - confirmed **no `--json` flag** on this script + +### 7.4 CLI vs docs mismatch (resolved) + +- `docs/agent-work-runtimes.md` and `docs/agent-work.md` previously described the pool-dispatch subcommand with `--limit` / `--execute` / `--json`; those references have been removed and the docs now direct to `agent-pool-kick` and `agent-work dispatch`. +- `python3 scripts/agent_work.py --help` includes subcommands: + - `dispatch`, but not a pool-dispatch subcommand + - the docs/CLI mismatch has been resolved. + +### 7.5 Cluster/MCP for dispatch checks + +- MCP tool check: + - `python3 scripts/cento_mcp_server.py --call-tool cento_cluster_status --arguments '{}'` + - mesh/socket state observed: + - linux: `connected` + - macos: `connected` + - iphone: `disconnected` +- CLI check: + - `python3 scripts/cento.sh cluster status` + - confirms same topology and local socket locations: + - `/tmp/cento-linux.sock` + - `/tmp/cento-mac.sock` + +## 8) Current canonical risks and immediate action items + +1. **MCP narrowness**: MCP remains intentionally limited; board and task work is only partially bridged, and many execution domains are CLI-only. +2. **pool-dispatch drift** *(resolved)*: stale pool-dispatch subcommand references in `docs/agent-work.md` and `docs/agent-work-runtimes.md` have been removed; the current surface is `agent-pool-kick` or per-issue `agent-work dispatch`. +3. **proof/status cohesion**: hard-proreq `execution_run.json` is complete but `proof`/`validation` top-level fields are not always materialized with one canonical status object, while receipts exist and are present in artifacts. +4. **agent-work runtime posture**: automated dispatch is effectively Claude-only due to `codex` weight set to `0`, so run planning is not the originally mixed profile. + +--- + +## Target Architecture + +_Integrated from `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/target_architecture.md`._ + +# Cento AI Native Target Architecture + +_Reference source: `docs/ai-cento-native-rework-research.md` (2026-05-04). This section is a canonical target design derived from observed Cento behavior and run artifacts, not a claim that all items are already implemented. + +## Objective + +Define a single Cento-native execution model where every AI request is converted into deterministic contracts, routed through MCP/CLI surfaces, executed in bounded lanes, and judged by evidence artifacts before handoff. + +This section is intentionally “AI-readable”: + +- strict named layers, +- explicit ownership and budgets, +- deterministic state transitions, +- source/receipt-driven evidence. + +## 1) Core layers + +### 1.1 Intent Route + +**Purpose:** classify inbound work into a concrete route and execution template before any planning/model call. + +**Inputs:** raw prompt, issue context, screenshots, route hints. + +**Observed evidence from current behavior:** hard-proreq and parallel-pipeline template routing already happen from API run posts and `schema_version` enforcement in existing run contracts. + +**Canonical output (`cento.intent_route.v1`):** + +```json +{ + "schema_version": "cento.intent_route.v1", + "request_id": "string", + "timestamp": "2026-05-05T05:13:19Z", + "source": { + "kind": "chat_prompt|issue|ui_run|cli", + "origin_url": "", + "issue_id": "" + }, + "routing": { + "project_id": "hard-proreq-project|parallel-pipeline-project|...", + "template_id": "hard-proreq-task|parallel-task|...", + "mode_hint": "planner-only|executor|evidence-only|handoff-only", + "requires_taskstream": false, + "requires_model": true, + "requires_human_review": false + }, + "runtime_constraints": { + "planner_model": "required|optional|forbidden", + "builder_model": "codex-fast|codex-medium|none", + "validator_tier": "deterministic-first|model-assisted|manual", + "muted_frontend_lane": true + }, + "required_context": [ + "project-manifest", + "templates", + "owned-paths", + "tool-surface" + ], + "next_action": "intent-route" +} +``` + +**Routing invariants (must-haves):** + +- If a matching MCP/CLI route exists, pick it before ad-hoc shell actions. +- If no durable route exists, return `blocked` with explicit evidence (`route_missing`), then fail before model mutation. +- Routes that require model calls must still produce this object before model invocation. + +--- + +### 1.2 Context Bundle + +**Purpose:** provide the planner and execution lanes with short-circuit evidence and bounded context. + +Current implementation currently writes a hard-proreq `mini_cento_context.json`; this design treats that as a subset of a generalized bundle. + +**Canonical object (`cento.context_bundle.v1`):** + +```json +{ + "schema_version": "cento.context_bundle.v1", + "source": "cento-intent-route", + "run_id": "ai-run-20260505T051319Z", + "inputs": ["prompt", "issue", "screenshot"], + "facts": { + "gather_context": {"status": "passed", "artifact": "workspace/runs/.../context_bundle/gather-context.json"}, + "dirty_state": {"status": "passed", "dirty_files": []}, + "tool_surface": { + "cento_tools_count": 45, + "mcp_tools_available": true + }, + "repo_health": { + "git_head": "", + "protected_paths_detected": [".git", ".env", "node_modules"] + } + }, + "path_contract": { + "owned_candidates": [], + "read_scope": ["docs/", "scripts/", "src/"], + "write_forbidden": [".git", ".env", "node_modules"] + }, + "evidence": { + "tool_snapshots": [], + "search_hits": [], + "recent_failures": [] + }, + "expires_at": "2026-05-05T05:43:19Z" +} +``` + +**Context rules:** + +- No writes in this layer. +- Should include evidence-bearing outputs rather than narrative claims. +- Expiry is required to avoid stale preflight. + +--- + +### 1.3 AI Run Envelope + +**Purpose:** one durable top-level contract that normalizes all current run families into a single inspectable object. + +Current codebase already has multiple manifest families (`story`, `validation`, `execution_run`, `workset`, various `hard_proreq` and `workset_receipt` artifacts). The envelope is missing today; this is the missing normalization point. + +**Canonical object (`cento.ai_run.v1`) (portable, source-agnostic):** + +```json +{ + "schema_version": "cento.ai_run.v1", + "run_id": "ai-run-20260505T051319Z", + "created_at": "2026-05-05T05:13:19Z", + "source": { + "kind": "chat_prompt|taskstream|issue|ui", + "origin": "https://.../dev-pipeline-studio#pipeline-flow", + "requester": "operator-id" + }, + "state": { + "lifecycle": "queued|running|completed|blocked|failed|accepted", + "validation": "pending|deterministic|model-review|manual-review", + "proof": "missing|partial|passed|failed" + }, + "route": "cento.intent_route.v1 reference", + "context": "cento.context_bundle.v1 reference", + "contracts": { + "story": "workspace/.../story.json", + "validation": "workspace/.../validation.json", + "workset": "workspace/.../workset.json", + "pipeline_manifest": "workspace/.../pipeline_manifest.json", + "execution_run": "workspace/.../execution/execution_run.json" + }, + "execution": { + "runtime": "cento-native|cento-workset|cento-build|cento-factory|api-openai-pro", + "lane_status": { + "planner": "running", + "builder": "queued", + "validator": "queued", + "integrator": "queued", + "evidence": "queued" + }, + "cost_usd": 0.0, + "budget_usd_cap": 20.0, + "budget_usd_soft": 10.0 + }, + "artifacts": { + "current": [], + "received": [], + "required": [ + "hard_proreq_evidence.json", + "validation_plan.json", + "workset_receipt.json", + "taskstream_evidence.json" + ] + }, + "receipts": [], + "handoff": { + "status": "review|blocked|ready|delivered", + "next_action": "run-validator|promote-workset|open-taskstream-issue|re-run" + } +} +``` + +--- + +### 1.4 Contract Planner + +**Purpose:** split execution into enforceable artifacts before any code mutation. + +Current pattern: hard-proreq emits schema-backed plan + ten story manifests + workset/integration/validation manifests. That behavior should be preserved but normalized by `ai_run`. + +**Inputs:** `intent_route`, `context_bundle`, operator objective. + +**Outputs:** one of: `story.json`, `workset.json`, `pipeline_manifest`, `validation_plan`, `integration_policy`. + +**Contract invariants:** + +- `execution_model` must be explicit (`deterministic`, `api-openai`, `api-openai-parallel`, `local-builder`, `factory`, `noop`). +- Every writing lane must declare `owned_paths` (non-empty unless `planning-only`). +- Every mutation lane must declare `read_paths`, `forbidden_paths`, and `runtime`. +- Validation plan must be generated before model-lane output is accepted. +- Planner output is only accepted when JSON schema matches and no unknown manifest references exist. + +**Ownership rules in planner output:** + +- Planner lane owns only planning artifacts, no repository writes. +- Builder lane may mutate only `owned_paths` after conflict checks. +- Integrator lane owns merge/apply decisions and final acceptance. + +--- + +### 1.5 Execution Lanes + +Execution is always decomposed into lane graph (can run serially/parallel). The graph below is canonical for this architecture. + +```text +Intent Route -> Context Bundle -> Contract Planner + -> Planner Lane (no writes) + -> Builder Lane (bounded writes) + -> Validator Lane (evidence-first) + -> Integrator Lane (serialized if multiple workers) + -> Evidence Lane (proof + handoff) +``` + +#### Planner Lane + +- **Function:** plan and decompose work. +- **Allowed runtime:** no-model or model, but no writes. +- **Inputs:** route + context + prior failures. +- **Outputs:** signed planner artifacts and lane assignments. +- **Failure:** schema mismatch, missing required context, unknown template, invalid route. + +#### Builder/Producer Lanes + +- **Function:** create patch proposals or worker artifacts. +- **Allowed runtime:** bounded workset/build/runtime profile (`codex-fast` and similar). +- **Path policy:** explicit owned-write paths only. +- **Evidence required:** request/response artifacts, worker outputs, command receipts. +- **Muting:** non-critical optional lanes (e.g. screenshot) can be muted and marked non-blocking. + +#### Validator Lane + +- **Function:** deterministic-first check of artifacts. +- **Allowed runtime:** command/file/url/screenshot validation by default; model review only if validation manifest requests escalation. +- **Mandatory:** produce evidence artifacts before marking validation passed. +- **Rule:** subjective interpretation is not a replacement for evidence. + +#### Integrator Lane + +- **Function:** converge worker outputs through a single, serial apply path. +- **Failure model:** any failed worker integration halts downstream gates unless `degrade` policy is explicit. +- **Receipts required:** integration receipt + evidence references. + +#### Evidence Lane + +- **Function:** collect and normalize proof, cost, and residual risk. +- **Artifacts:** evidence bundle, budget receipts, proof statuses, handoff notes. +- **Output:** review-ready summary with Delivered/Validation/Evidence/Residual Risk structure (current validator lane guidance already follows this format). + +--- + +### 2) Lifecycle state model + +Use one canonical lifecycle for each run and normalize source-layer states into it. + +#### 2.1 Run lifecycle (high-level) + +1. `created` +2. `routed` +3. `context_bundled` +4. `contract_ready` +5. `queued` +6. `running` +7. `validated` +8. `evidence_ready` +9. `handoff_ready` +10. `accepted` + +Allowed terminal states: `blocked`, `rejected`, `failed`. + +#### 2.2 Lane state set + +- `accepted` +- `completed` +- `running` +- `queued` +- `blocked` +- `failed` +- `muted` +- `separate-flow` + +#### 2.3 Source normalization map (canonical) + +- Existing accepted/merged/passed -> `accepted`. +- Completed/running statuses from execution steps -> `running`. +- blocked/dependency-blocked/budget-blocked/budget-exceeded -> `blocked`. +- failed -> `failed`. +- muted/separate-flow/deferred -> `muted` (non-blocking lane). + +#### 2.4 State transitions (contract) + +- `created -> routed -> context_bundled` is automatic when route and context are persisted. +- `contract_ready -> queued` requires planner manifest success and path ownership checks. +- `queued -> running` after lane dispatch confirmation. +- `running -> validated` only after validator returns evidence-backed pass/fail. +- `validated -> evidence_ready` requires evidence bundle existence and integrity. +- `evidence_ready -> handoff_ready` requires review constraints met (e.g., deterministic check pass, required evidence refs). +- `handoff_ready -> accepted` only via review/action lane. + +Failure transitions: + +- Any stage can transition to `blocked` if prerequisites missing. +- Any hard runtime failure -> `failed` and no downstream mutation. + +--- + +### 3) Data flow + +```text +Operator / Taskstream / API + -> route.id decision + -> context artifacts + git/tool snapshot + -> ai_run envelope creation + -> contracts: story / workset / validation / pipeline manifest + -> dispatch lanes + -> model planner output + -> workset/build output artifacts + -> integration receipts + -> validator receipts + costs + -> evidence bundle + -> Taskstream or UI handoff +``` + +**Mandatory envelope linkage for every artifact path:** + +- Every artifact includes `run_id` and `run_manifest` backlink where supported. +- Evidence cards must carry run references to avoid UI/run ambiguity. +- Lane-specific receipts must be retrievable by both MCP and UI with the same path convention. + +--- + +### 4) Ownership, budget, and runtime rules + +#### 4.1 Ownership + +- **Owner of contract:** `cento_intent_route` + Planner lane. +- **Owner of repo changes:** Builder/Workset lanes only. +- **Owner of evidence claims:** Evidence lane and Validator lane. +- **Owner of release/status handoff:** Integrator/Validator gate path. + +#### 4.2 Budget rules + +- Hard-cap and soft-cap are explicit in run execution config (observed defaults in current hard-proreq planning: soft 10.00 USD, hard 20.00 USD target). +- All runs must track: + - `budget_usd_soft` + - `budget_usd_cap` + - `budget_spent` + - `budget_breaches` +- If runtime costs are not measurable, run is blocked as `evidence_missing` rather than silently accepted. +- A blocked-lane rule for cost: any hard-cap exceed transitions to `blocked` and records `budget_exceeded`. + +#### 4.3 Runtime rules + +- No-model-first for context/route/validation command lane. +- Planner may be model-backed but cannot write repo. +- Builder mutations require explicit runtime profile and path ownership. +- Validator uses deterministic commands first; model review is explicit escalation only. +- Proof/muted lanes cannot block unless explicitly configured as `blocking`. + +--- + +### 5) Evidence gate + +Evidence gate is the control boundary before any handoff. + +#### 5.1 Evidence inputs + +- Schema checks for each referenced manifest. +- Required artifact existence. +- Deterministic command/test results. +- Optional screenshot/file capture. +- Budget and receipt consistency. + +#### 5.2 Source-dependent proof resolver + +The gate resolves proof by run source: + +- hard-proreq source -> `hard_proreq_evidence.json` + `validation_plan.json` + validator outputs +- workset source -> `workset_receipt` +- build source -> build/apply/evidence receipts +- factory source -> factory integration/release receipts + +#### 5.3 Gate outputs + +- `proof_status = passed` only when required evidence references resolve. +- `proof_status = failed` when any required item is missing/invalid. +- `proof_status = partial` when optional lanes are missing but not required. +- `proof_status = missing` when evidence lane has no artifacts. + +--- + +### 6) Evaluation loop + +To keep the runtime self-correcting, every run must write evaluation metrics tied to `ai_run`. + +Required metrics: + +- route accuracy (classification confidence, re-route count) +- time-to-contract (prompt to planner complete) +- time-to-proof (prompt to evidence_ready) +- contracts complete rate +- validator deterministic pass rate +- failed/blocked causes by class +- percentage of direct shell calls bypassing Cento/MCP +- residual human risk categories and review comments +- cost per accepted run + +Loop behavior: + +1. append metric row at each state transition, +2. aggregate nightly into a run-quality report, +3. expose regression alerts if any threshold drifts (e.g., blocked rate, missed evidence). + +--- + +### 7) Failure modes and controls + +| Failure mode | Typical detection | Control action | +| --- | --- | --- | +| Route mismatch | route schema invalid / no template match | `blocked` with `route_not_supported`, request user reroute | +| Stale context | context artifact expired or missing | `blocked` + rebuild context bundle | +| Unowned path write | ownership overlap or forbidden prefix write | `blocked`; auto-rewrite planner contracts | +| Model fallback ambiguity | plan generated from cached/fallback source but source not recorded | fail evidence gate; require explicit `source_note` | +| Validation mismatch | UI proof says complete but receipts missing | fail evidence gate and sync status normalizer | +| Validation command failure | required command exits !=0 or file empty | mark `failed`, keep artifacts and return recovery guidance | +| Budget breach | `budget_spent > budget_usd_cap` | `blocked`, freeze execution, escalate to operator | +| Receipt desync | step status not reflected in run envelope | reject status publish, re-run proof resolver | +| Human-review drift | missing residual-risk section | reject handoff gate | + +--- + +## 8) Parallel Spark lane split for execution + +Use Spark lanes as independent contributors with strict artifact contracts. + +- **Lane A – Routing + Context Extraction** + - owns: intent route, context bundle generation, preflight checks + - outputs: `intent_route`, `context_bundle`, preflight status + - success criteria: route complete and context valid + +- **Lane B – Contract and Planner Fabrication** + - owns: story/workset/validation contract generation and manifest normalization + - outputs: `story`, `validation`, `workset`, contract artifacts linked to `ai_run` + - success criteria: explicit ownership, paths, runtime, and budget fields + +- **Lane C – Execution & Evidence (build + validation + gate)** + - owns: model/Builder lanes, workset dispatch, validator execution, evidence collection, proof resolution + - outputs: lane receipts, evidence bundle, cost receipts, review-ready handoff + - success criteria: evidence completeness and deterministic checks + +- **Lane D – Runtime/Compliance and Recovery (coordination lane)** + - owns: state normalization, status harmonization, escalation routing, failure taxonomy, and post-run metrics update + - outputs: stable status map and evaluation records + - success criteria: no blocked/failed silent transitions + +### Coordination contracts + +- Contracts between lanes are file-backed and JSON schema checked. +- No lane may mutate artifacts owned by another lane except through approved handoff. +- All lanes must write append-only event records (`events.ndjson` style) with `run_id`, `lane`, `status_before`, `status_after`, `artifact`, `error_code`. + +--- + +### Minimal coordination checklist (for any lane) + +- `[ ]` route + context artifacts are present and valid +- `[ ]` ownership is explicit for every writable path +- `[ ]` budgets are loaded (`soft`, `cap`, currency) +- `[ ]` runtime profile is explicit (`local`, `api`, `noop`, `fixture`) +- `[ ]` evidence IDs map to real files/commands +- `[ ]` proof gate can calculate PASS/FAIL/PARTIAL +- `[ ]` state transitions are deterministic and recorded + +--- + +## 9) Status of source evidence vs targets + +- **Already observed today:** hard-proreq lane decomposition, muted screenshot lane, schema-backed planning requests, workset manifests, deterministic validation orientation, and dedicated evidence artifacts. +- **Target gap to implement:** universal `cento.ai_run.v1`, normalized proof source resolver, and unified UI proof/validation status mapping across pipeline and workset routes. +- **Risk note:** this architecture is implementation-ready, but execution of every section above depends on explicit coding and schema/test coverage. + +--- + +## Proposed Interfaces + +_Integrated from `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/interfaces.md`._ + +# AI Native Interface Draft (Proposed) + +## Scope and status + +This is a **proposed**, future-only interface contract for Spark coordination. It is intentionally canonical, machine-readable-first, and narrow. + +All fields marked `proposed: true` are new and not current in the repo. + +## Cross-lane split (for execution planning) + +- Lane 1 (schemas): own the contract IDs and JSON field contracts. +- Lane 2 (Dev Pipeline): implement API/MCP read/start/show/proof and evidence resolution. +- Lane 3 (runtimes/scan): implement `scan` and runtime surface, then wire to AI handoff. +- Lane 4 (this lane): define complete proposed interfaces, coordinate signatures, and keep CLI/MCP behavior aligned. + +## Proposed schema: `cento.intent_route.v1` + +```json +{ + "schema_version": "cento.intent_route.v1", + "proposed": true, + "route_id": "route-hard-proreq-2026-05-05-01", + "request_id": "chat-2026-05-05-0513", + "input": { + "raw_text": "...", + "screenshot": "optional/path.png", + "issue_id": "optional", + "source": "chat|ui|api|issue|file", + "tenant": "cento-local", + "cwd": "." + }, + "policy": { + "prefer_mcp": true, + "allow_model_fallback": true, + "risk_hint": "low|medium|high", + "max_budget_usd": 20.0 + }, + "decision": { + "route": "hard-proreq|generic-task|workset|build|factory|scan|runtime-list|docs-evidence|unknown", + "requires_taskstream": false, + "requires_model": true, + "requires_human": false, + "validation_mode": "no-model|model|human-review", + "next_command": "cento ai-run plan --route hard-proreq", + "confidence": 0.0 + }, + "selected_inputs": ["project_id", "template_id", "runtime_profile"], + "generated_at": "2026-05-05T05:13:19Z" +} +``` + +`cento intent-route` (proposed CLI) and `cento_intent_route` (proposed MCP) map free-form requests into the above object. + +CLI behavior (proposed): + +- `cento ai-route --source ui|chat|issue|file --request-id ...` +- `cento ai-route --json --dry-run ...` +- Deterministic fields: same input plus route classification and suggested next command. + +MCP behavior: + +- Tool: `cento_intent_route` +- Input: `{ "raw_text": string, "source": "chat|ui|issue|file", "request_id": string, "risk_hint": "low|medium|high" }` +- Output: command/CLI-like payload plus full `cento.intent_route.v1` object. + +## Proposed schema: `cento.context_bundle.v1` + +```json +{ + "schema_version": "cento.context_bundle.v1", + "proposed": true, + "bundle_id": "context-hard-proreq-20260505T051319Z", + "run_dir": "workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/contexts/..", + "source_route": "route-id", + "collected_at": "2026-05-05T05:13:19Z", + "gather": { + "tools": ["cento tools", "cento platforms", "cento runtime list --json", "cento scan --query"], + "dirty_repo": true, + "dirty_paths": ["scripts/foo.py"], + "protected_paths_touched": [".cento/config"], + "context_files": ["workspace/runs/.../mini_cento_context.json"] + }, + "constraints": { + "blocked_actions": ["openai:network-call", "cross-repo-edit"], + "allow_creates": false, + "allow_unowned_paths": false + }, + "evidence_refs": [ + { + "type": "json", + "path": "workspace/runs/.../context_scan.json", + "source": "scan/cento_scan" + } + ] +} +``` + +CLI behavior (proposed): + +- `cento ai-context --route-id ... --json --collect` +- Emits a persisted bundle path and prints JSON when `--json`. +- Uses existing `cento gather-context --no-remote` and deterministic local scans. + +MCP behavior: + +- Tool: `cento_context_bundle` +- Output fields: includes `schema_version`, bundle path, collected constraints, tool registry summary, scan counters, and artifact refs. + +## Proposed schema: `cento.ai_run.v1` (new top-level envelope) + +```json +{ + "schema_version": "cento.ai_run.v1", + "proposed": true, + "id": "ai-run-hard-proreq-2026-05-05T051319Z", + "status": "planned|running|blocked|complete|failed", + "route_id": "route-hard-proreq-...", + "created_at": "2026-05-05T05:13:19Z", + "source": { + "kind": "chat_prompt|issue|pipeline|scan", + "value": "..." + }, + "route": { + "decision_id": "route-id", + "kind": "hard-proreq", + "requires_model": true, + "requires_human": false, + "validation_mode": "no-model|model|human-review" + }, + "context": { + "bundle": "workspace/runs/.../context_bundle.json", + "commands": ["cento context --bundle ...", "cento scan --query ..."] + }, + "contracts": { + "pipeline_manifest": "workspace/runs/dev-pipeline-studio/docs-pages/latest/pipeline_manifest.json", + "workset": "workspace/runs/.../workset.json", + "build_manifest": "workspace/runs/.../manifest.json", + "story": "workspace/runs/.../story.json", + "validation": "workspace/runs/.../validation.json" + }, + "execution": { + "kind": "dev_pipeline|workset|build|factory", + "runtime": "cento-native + GPT-pro", + "model": "gpt-5.x", + "run_id": "hard-proreq-task-hard-proreq-project-...", + "artifacts_root": "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/..." + }, + "receipts": [ + { + "kind": "validation|integration|apply|proof", + "path": ".../validation_receipt.json", + "status": "passed|failed|muted|missing" + } + ], + "proof": { + "status": "passed|failed|missing", + "missing": ["..."], + "next_action": "run validator|re-collect artifact|blocked-by-policy", + "score": 0.0 + }, + "metrics": { + "cost_usd": 0.12, + "duration_ms": 12000, + "validation_hits": 3, + "missing_inputs": 0 + } +} +``` + +CLI behavior (proposed): + +- `cento ai-run create --route-id ... --context-bundle ... --json` +- `cento ai-run show --json` +- `cento ai-run check --require-proof` validates envelope and evidence. + +MCP behavior: + +- `cento_ai_run_create`: creates envelope and links contracts. +- `cento_ai_run_show`: reads envelope, contracts, run state, receipts, and proof summary. +- `cento_ai_run_check`: verifies contract/receipt consistency and emits canonical missing/error list. + +## Proposed evidence check contract + +### `cento.evidence_check.v1` + +```json +{ + "schema_version": "cento.evidence_check.v1", + "proposed": true, + "subject": { + "kind": "ai_run|dev_pipeline_run|build|workset|factory", + "id": "ai-run-hard-proreq-..." + }, + "status": "ok|warn|fail", + "checks": [ + { + "name": "schema", + "status": "ok", + "details": "schema_version present and valid" + } + ], + "artifacts": { + "required": ["pipeline_manifest.json", "validation_receipt.json"], + "found": ["pipeline_manifest.json"], + "missing": ["validation_receipt.json"] + }, + "receipts": { + "validation": "workspace/runs/.../validation_receipt.json", + "proof": "workspace/runs/.../evidence_bundle.json", + "model": "cento.openai-worker" + }, + "recommended_next": ["run cento_ai_run_check", "re-run validator", "attach evidence bundle"], + "checked_at": "2026-05-05T05:13:19Z" +} +``` + +CLI behavior: + +- `cento evidence check --json` +- Option `--require-proof` exits non-zero unless proof and minimum deterministic checks pass. + +MCP behavior: + +- Tool: `cento_evidence_check` +- Input: `{"subject_kind":"ai_run|workset|build|factory", "subject_id":"...", "strict":false, "require_proof":true}` +- Output: status + missing/mismatch matrix + command replay bundle. + +## Dev Pipeline interfaces (read/start/show/proof) + +### Shared run object (proposed `cento.dev_pipeline_run.v1`) + +```json +{ + "schema_version": "cento.dev_pipeline_run.v1", + "proposed": true, + "run_id": "hard-proreq-task-hard-proreq-project-2026...", + "pipeline": { + "project_id": "hard-proreq-project", + "template_id": "hard-proreq-task", + "status": "queued|running|completed|failed", + "source": "api|ui|cli", + "started_at": "2026-05-05T05:13:19Z", + "elapsed_ms": 8400 + }, + "inputs": [ + { "id": "operator-thoughts", "kind": "questionnaire", "source": "user", "status": "ok" } + ], + "proof": { "status": "pending|passed|failed", "source": "run-kind" }, + "artifacts_root": "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/...", + "artifact_count": 12, + "links": { + "state": "/api/dev-pipeline-studio?project=...&template=...", + "manifest": "pipeline_manifest.json" + } +} +``` + +API behavior (existing endpoints, behavior alignment target): + +- `GET /api/dev-pipeline-studio?project=...&template=...` -> studio state + selected template metadata. +- `POST /api/pipeline-runs` accepts a proposal-shaped request and returns run envelope. +- `GET /api/pipeline-runs/` (proposed new, if not present): return `cento.dev_pipeline_run.v1` for a single run. +- `GET /api/pipeline-runs//proof` (proposed): source-dependent proof object. + +MCP tools (proposed): + +- `cento_dev_pipeline_state`: read state + templates. + + - Input: `{ "project_id":"", "template_id":"", "run_id":"", "include_artifacts":true }` + - Output: state object plus curated `proposed`/`current` proof fields. + +- `cento_dev_pipeline_run_start`: start a pipeline run. + + - Input: `{ "project_id":"...", "template_id":"...", "inputs":[{ "id":"...", "kind":"questionnaire", "value":... }], "request_id":"...", "dry_run":false, "schema_version":"cento.pipeline_run_request.v1" }` + - Output: `{ "run_id": "...", "run": "cento.dev_pipeline_run.v1", "queued_artifacts": [...] }` + +- `cento_dev_pipeline_run_show`: fetch per-run payload. + + - Input: `{ "run_id":"...", "artifact_paths":true }` + - Output: `cento.dev_pipeline_run.v1`. + +- `cento_dev_pipeline_run_proof`: source-aware proof resolver. + + - Input: `{ "run_id":"...", "check_mode":"minimum|strict" }` + - Output: `cento.evidence_check.v1`-compatible proof section. + +CLI behavior (proposed): + +- `cento dev-pipeline state --project ... --template ... --json` +- `cento dev-pipeline run start --project ... --template ... --input-file ... --json` +- `cento dev-pipeline run show --json` +- `cento dev-pipeline run proof --strict --json` + +## Runtime list interface + +Existing CLI target behavior to align: + +- `cento runtime list --json` returns profile list with status + executable availability. + +Proposed MCP tool: + +### `cento_runtime_list` + +Input: + +```json +{ "json": true, "name": "", "require_executable": false, "include_errors": false } +``` + +Output shape (minimal): + +```json +{ + "schema_version": "cento.runtime_list.v1", + "proposed": true, + "profiles": [ + { + "name": "codex-fast", + "type": "command|fixture", + "status": "passed|failed|warning", + "timeout_seconds": 180, + "max_changed_files": 80, + "executable": "codex", + "executable_available": true, + "errors": [], + "warnings": ["..."] + } + ] +} +``` + +CLI/GUI behavior: + +- `cento runtime list --json` accepted in machine mode. +- `cento runtime check --json --require-executable` for single profile. +- MCP returns same shape, and write tools must reference it for deterministic runtime choice. + +## Scan interface + +CLI behavior (existing + proposed additions): + +- Existing: `cento scan --query [--no-open|--regex|--case-sensitive|--limit]` +- Proposed extension: `--json` to print machine payload directly (without HTML lookup dependency). + +Proposed MCP tool: `cento_scan` + +Input: + +```json +{ "schema_version": "cento.scan_request.v1", "proposed": true, "query": "agent_work", "root": ".", "regex": false, "case_sensitive": false, "limit": 12, "open_browser": false, "json": true } +``` + + +Output: + +```json +{ + "schema_version": "cento.scan_result.v1", + "proposed": true, + "query": "agent_work", + "root": "/home/alice/projects/cento", + "matched_files": 31, + "total_matches": 188, + "scanned_files": 900, + "top_files": [ { "relative_path": "scripts/agent_work_app.py", "count": 12 } ], + "summary_path": "workspace/runs/scan-onepager/latest/summary.json", + "html_path": "workspace/runs/scan-onepager/latest/index.html" +} +``` + +## Build tool interfaces (proposed MCP wrappers) + +Each MCP write tool is read-only-safe by default unless explicitly invoked. + +Proposed canonical request envelope: + +```json +{ + "schema_version": "cento.build_command_request.v1", + "proposed": true, + "command": "init|check|worker_run|integrate|apply|artifact_check|bundle_synthesize|receipt", + "args": {}, + "json": true, + "require_ok": false, + "timeout_seconds": 120 +} +``` + +Standard MCP response: + +```json +{ "ok": true, "exit_code": 0, "command": ["cento", "build", "..."], "stdout": "...", "stderr": "...", "artifacts": [".cento/builds/..."] } +``` +Commands to expose: + +- `cento_build_init`: map to `cento build init`. +- `cento_build_artifact_check`: map to `cento build artifact check`. +- `cento_build_worker_run`: map to `cento build worker run`. +- `cento_build_integrate`: map to `cento build integrate`. +- `cento_build_apply`: map to `cento build apply`. +- `cento_build_receipt`: map to `cento build receipt`. + +## Workset tool interfaces (proposed MCP wrappers) + +Canonical `cento.workset` command envelope: + +```json +{ + "schema_version": "cento.workset_command_request.v1", + "proposed": true, + "command": "check|run|execute|materialize_artifact", + "workset": ".cento/worksets//workset.json", + "max_parallel": 3, + "runtime": "api-openai|fixture|local-command", + "runtime_profile": "codex-fast", + "budget_usd": 3.0, + "max_budget_usd": 5.0, + "apply": false, + "json": true +} +``` + +Core MCP tools: + +- `cento_workset_check`: calls `cento workset check`, returns validation summary. +- `cento_workset_run`: calls `cento workset run`, returns `.cento/worksets//workset_receipt.json`. +- `cento_workset_execute`: calls `cento workset execute`, returns task status + `workset_receipt` + `workset_evidence`. +- `cento_workset_materialize_artifact`: calls `cento workset materialize-artifact`. + +CLI behavior: + +- keep existing behavior and ensure JSON prints include artifact paths when `--json`. + +## Factory tool interfaces (proposed MCP wrappers) + +Canonical request payload: + +```json +{ + "schema_version": "cento.factory_command_request.v1", + "proposed": true, + "command": "intake|plan|materialize|queue|lease|dispatch|collect|validate|integrate|release|runtime_list|runtime_show", + "run_dir": "workspace/runs/factory/", + "request": "plan and deliver feature", + "risk": "low|medium|high", + "lane": "builder|validator|coordinator", + "json": true +} +``` + +Proposed MCP tools: + +- `cento_factory_intake`: wraps `cento factory intake`. +- `cento_factory_plan`: wraps `cento factory plan`. +- `cento_factory_materialize`: wraps `cento factory materialize`. +- `cento_factory_queue`: wraps `cento factory queue`. +- `cento_factory_lease`: wraps `cento factory lease`. +- `cento_factory_dispatch`: wraps `cento factory dispatch`. +- `cento_factory_collect`: wraps `cento factory collect`. +- `cento_factory_validate`: wraps `cento factory validate`. +- `cento_factory_integrate`: wraps `cento factory integrate`. +- `cento_factory_status`: wraps `cento factory status`. +- `cento_factory_release`: wraps `cento factory release`. +- `cento_factory_runtime_list` and `cento_factory_runtime_status`: wraps `cento factory runtime` subcommands. + +Each factory MCP call returns the same wrapped command envelope (`ok/exit_code/command/stdout/stderr`) plus resolved artifact pointers: + +- `workspace/runs/factory//factory-plan.json` +- `workspace/runs/factory//queue/queue.json` +- `workspace/runs/factory//integration/integration-state.json` +- `workspace/runs/factory//delivery-status.json` + +## Validation and proof mapping by run kind + +Recommended source mapping in the proof resolver: + +- `cento-workset` -> `.cento/worksets//workset_receipt.json` +- `cento-build` -> `.cento/builds//integration_receipt.json` + `.cento/builds//apply_receipt.json` + `.cento/builds//taskstream_evidence.json` +- `cento-hard-proreq` -> hard-proreq evidence + `validation_plan.json` + validator receipts +- `factory` -> `integration/integration-state.json` + `delivery-status.json` + +Status rule: + +- `passed` if at least one required receipt exists and no failed required checks. +- `warn` if optional receipts missing. +- `fail` if required receipts missing/failed. + +## Alignment note + +These are proposed interfaces only. No existing behavior is changed by this file; it is the canonical plan for Spark Lane implementation. + +--- + +## Dev Pipeline Gaps And Fixes + +_Integrated from `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/dev_pipeline_gaps.md`._ + +# Dev Pipeline Gaps: Proof, Validation, Run Selection, and UI Trust + +This section records proof/validation/selection gaps in the Dev Pipeline Studio execution path, with concrete fixes and test additions. It is scoped to: +- `scripts/agent_work_app.py` +- `scripts/dev_pipeline_hard_proreq.py` +- `tests/test_dev_pipeline_delivery.py` +- `docs/dev-pipeline-run-contracts.md` + +## 1) Proof / receipt resolution is fragmented + +Observed behavior: +- Hard-proreq run payload starts with `source: "cento-hard-proreq-pro"` and `artifacts` from `dev_pipeline_hard_proreq_artifacts(...)`. +- Hard-proreq finish path rewrites `run_payload["artifacts"]` from that same helper and emits only the list for events. +- Workset/delivery runs compute artifacts from `dev_pipeline_delivery_artifacts` using execution run + receipt data. +- Evidence cards are assembled from default cards and per-template config in `dev_pipeline_base_evidence_cards`. + +Gap: +- There is no canonical proof resolver object that indicates which artifact set is the authoritative proof for a run. UI and status logic can therefore read different proof sources for the same run. + +Fix: +- Introduce `dev_pipeline_resolve_proof(...)` (single function) that returns: + - `proof_source`: `hard-proreq|workset|factory|manifest` + - `proof_provider`: `run_payload|run_receipt|history` + - `required_artifacts` + - `observed_artifacts` + - `missing_artifacts` + - `proof_status` (canonical) + - `proof_confidence` (numeric) +- Persist it as `execution_run["proof_summary"]`. +- In `dev_pipeline_execution_flow`, derive `execution_flow["proof"]` only from this summary so UI evidence/status cards align with an explicit resolver. + + +## 2) Validation status normalization is incomplete + +Observed behavior: +- `dev_pipeline_validation_status` supports `passed|configured|queued|warning|failed|manual-review`. +- Aggregate validator receipt status in `dev_pipeline_write_validation_outputs` is only: `failed` > `passed` > `configured`. +- Stage/status mappers flatten values across contexts: + - `dev_pipeline_execution_status_label` maps accepted/applied/passed/merged to `completed`. + - mutes map to `muted`. + - stage reducer treats `muted`, `separate-flow`, `deferred` as completed. +- `validation_results.passed` in execution flow counts `passed|completed` only. + +Gap: +- `warning` and `manual-review` lose semantic precision and can be downgraded via aggregation or stage mapping. +- Status vocabularies differ across validator config, execution stages, evidence cards, and UI labels. + +Fix: +- Define one canonical status layer: + - `proof_status`: `passed|warning|manual-review|failed|blocked|muted|running|queued|configured|missing` + - `pipeline_status`: `running|queued|completed|blocked|failed` +- Route all status text through one normalizer (validation, execution, evidence, cards, stage reducers). +- Update aggregate validation to return `warning` when any validator yields warning/manual-review, not `configured`. +- Update validation UI counts to treat non-`passed` states as non-success. + + +## 3) Live/fallback/cached provenance is implicit + +Observed behavior: +- `dev_pipeline_execution_flow` resolves runs by: + 1. `execution/execution_run.json` + 2. optional `selected_run_id` history run + 3. first matching history row +- `dev_pipeline_execution_history` only stores `status`, `started`, `finished`, `path`, `active`. + +Gap: +- UI cannot trust if a row is live, explicitly selected, stale fallback, or historical cache. + +Fix: +- Extend history row schema with: + - `provenance`: `live|selected|fallback|cached` + - `resolved_from`: resolved file path + - `selection_reason`: short machine reason +- Extend execution flow output with: + - `run_selection`: `{ requested_run_id, resolved_run_id, resolved_from, resolved_as, is_live, selection_reason }` +- Make fallback-to-history behavior explicit in resolver logic and persisted metadata. + + +## 4) Latest-run behavior can drift from authoritative source + +Observed behavior: +- If no active run is found, flow reconstructs a synthetic `run_id` from timestamps. +- If manifest active id is stale or wrong pipeline, it is blanked. +- History injection can synthesize rows even when `execution/execution_run.json` is stale. + +Gap: +- Determinism is mostly incidental; status shown to operators can represent an older run when newer history exists. + +Fix: +- Add `dev_pipeline_resolve_active_execution_run(root, expected_pipeline, selected_run_id)` returning: + - chosen `run_payload` + - provenance + - `is_live` + - `is_stale` +- Use this same resolver from: + - `dev_pipeline_execution_flow` + - `dev_pipeline_studio_state` + - finish/append event paths when needed. + + +## 5) Evidence artifacts are assembled but weakly trusted + +Observed behavior: +- Hard-proreq evidence artifact is generated by `command_evidence` as `hard_proreq_evidence.json`, with per-artifact `exists` flags. +- Base evidence cards default statuses from static strings or presence heuristics (`title_status`), not proof checks. +- Execution summary artifacts are built by appending fallback paths such as `evidence/pipeline_receipt.json`, `validation/validation_receipt.json`, `execution/execution_run.json`. + +Gap: +- A missing required artifact can still render non-error card states when status text is optimistic. +- Evidence trust is not represented as a single computed state. + +Fix: +- Require explicit evidence contract per run: + - `evidence_bundle.json` contains required proof artifact names + checks performed. + - `evidence_bundle_manifest.json` tracks present/missing + severity per item. +- Render evidence/integration/validation cards from this manifest, not from static template status fallbacks. +- Surface `evidence_trust` (`high|medium|low`) in execution_flow and UI so trust level is explicit. + + +## 6) UI status trust currently depends on template/card-level text + +Observed behavior: +- Integration/validator/evidence cards frequently derive from string labels (`title_status`) with fallback values, not proof-level outcome. +- Stage status is often inferred from step status buckets; this can diverge from proof receipt status. + +Gap: +- The UI can show “passed/completed” even if validators produce `warning` or required evidence is missing. + +Fix: +- Add `pipeline_ui_state` block to studio response: + - `proof.status` + - `execution.status` + - `validation.status` + - `evidence.status` + - `confidence` +- Bind pipeline cards to these normalized status fields only. +- Require evidence status gating before transition to handoff-complete UI state. + + +## 7) Tests to add now + +Current tests validate inputs, seeds, parallel paths, and optional image handling, but do not cover proof/resolution status contracts. + +Add these test classes in `tests/test_dev_pipeline_delivery.py`: +- `test_proof_resolver_unifies_hard_proreq_and_workset_sources` +- `test_status_normalizer_maps_all_variants_to_canonical` +- `test_history_rows_expose_live_selected_fallback_cached` +- `test_execution_flow_prefers_latest_authoritative_run_when_live_stale` +- `test_evidence_manifest_blocks_when_required_artifact_missing` +- `test_validation_warning_manual_review_not_counted_as_passed` +- `test_ui_execution_flow_status_matches_proof_summary` + + +## 8) Implementation sequence (smallest safe increments) + +1) Add canonical status normalizers and shared proof status constants. +2) Add explicit `resolve_execution_run` + `resolve_proof` helpers. +3) Wire execution flow and studio response to resolved proof status + provenance. +4) Add evidence manifest validation + trust score output. +5) Update tests with table-driven and synthetic run fixtures. + +--- + +## Skills And Runtime Policy + +_Integrated from `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/skills_runtime.md`._ + +# AI Native Skills and Runtime Policy (Canonical Draft) + +## Scope and issue link + +This is the canonical, machine-facing policy draft for **Taskstream issue 1000185**. + +It converts `docs/ai-cento-native-rework-research.md` into a concrete execution policy: + +- current skill guidance, +- proposed thin skills, +- runtime and model-selection policy, +- deterministic-first validation with explicit escalation, +- strong-model planning constraints, +- Spark/Codex bounded builders, +- no-model-only paths, +- API/image/Pro gating, +- and skill authoring rules. + +Status: **current and future sections mixed; explicit `proposed` flags mark non-implemented behavior.** + +## Policy data shape + +```json +{ + "schema_version": "cento.skill_runtime_policy.v1", + "issue_id": 1000185, + "run_dir": "workspace/runs/ai-cento-native-execution-plan/20260505T051319Z", + "proposed": false, + "created_for": "ai-cento-native-rework", + "timestamp": "2026-05-05T05:13:19Z", + "components": { + "current_skills": [ + "cento-native", + "cento-requirements-manifest" + ], + "proposed_skills": [ + "cento-ai-run", + "cento-validator", + "cento-evidence-handoff" + ], + "current_mcp_surface": [ + "agent-work", + "cluster", + "bridge", + "story", + "context", + "platforms" + ], + "proposed_mcp_extensions": [ + "cento_ai_run_*", + "cento_dev_pipeline_*", + "cento_scan", + "cento_runtime_list", + "cento_evidence_check", + "cento_build_init", + "cento_workset_execute", + "cento_factory_plan" + ], + "runtime_profiles_observed": [ + "codex-fast", + "fixture-valid", + "python-fixture" + ] + } +} +``` + +## 1) Current skill guidance baseline + +### 1.1 `cento-native` (installed, active) + +Observed behavior in the current repository and skill guidance: + +- starts with Cento discovery and repo/tool reality, +- prefers MCP/CLI over free-form scripting, +- routes Cento feature work through Taskstream, +- uses temp/cluster/one-off command paths for one-off tasks, +- preserves dirty repo state when operating on user workspaces, +- and keeps implementation instructions thin. + +This is the right base layer because it forces tool-first operation before agent improvisation. + +### 1.2 `cento-requirements-manifest` (installed, active) + +- turns screenshots/mockups/notes into structured requirements artifacts, +- emits `cento.requirements_manifest.v1` style outputs, +- optionally scaffolds a draft `story.json`, +- should not dispatch worker execution itself. + +### 1.3 Gap from validation against current run + +From validation evidence: +- only current installed skills are `cento-native`, `cento-requirements-manifest`, plus generic helpers, +- proposed skills `cento-ai-run`, `cento-validator`, and `cento-evidence-handoff` are not installed yet. + +This gap is intentional in phase-0 but must close before the run model becomes durable across all routes. + +## 2) Proposed thin skill set (no business logic) + +Each new skill must be an adapter: map task -> command/tool -> schema artifact, not a planner/executor of long logic. + +### 2.1 `cento-ai-run` (proposed) + +**Use case:** launch or continue any AI execution flow from an intent. + +Inputs: +- route decision (existing `cento.intent_route.v1`), +- context bundle, +- optional workset/manifest hints. + +Actions: +- run `cento_intent_route`, +- run `cento_context_bundle`, +- create `cento.ai_run.v1` via `cento_ai_run_create`, +- route to Dev Pipeline Studio/build/workset/factory based on template and validation mode. + +Outputs: +- run id (`id`), +- selected runtime profile (`runtime_profile`), +- acceptance handoff target (`handoff_target`), +- evidence manifest pointer. + +No direct file mutation except explicit run artifacts it owns by contract. + +### 2.2 `cento-validator` (proposed) + +**Use case:** independent and reproducible validation lane. + +Inputs: +- `story.json`, +- `validation.json`, +- `run_dir`. + +Actions: +- run deterministic commands from validation manifest first, +- capture screenshot evidence when declared, +- emit validator evidence and receipts, +- mark unresolved/subjective checks as manual-review or warning. + +Constraints: +- no repo mutation unless explicitly declared in task and owned path policy, +- model use must be explicit and only for escalation (never first response), +- should not be used as the only judge for completion. + +### 2.3 `cento-evidence-handoff` (proposed) + +**Use case:** package manager-facing proof and lane transition. + +Inputs: +- run/envelope identifier, +- validation outputs, +- mandatory artifact refs. + +Actions: +- produce evidence hub index, +- emit Delivered/Validation/Evidence/Risk summaries, +- draft Taskstream handoff/notes, +- verify every required artifact link resolves. + +Gate: +- cannot mark as “ready” if proof obligations remain unresolved. + +## 3) Runtime and model policy + +### 3.1 Runtime classes + +| Runtime | Purpose | Typical actor | Default gate | +|---|---|---|---| +| `no-model` | deterministic checks + read-only planning | validator/planner preflight | mandatory baseline | +| `cheap-model` | low-risk synthesis / summarization | small/fast model workers | optional and auditable | +| `strong-model` | decomposition + architecture planning | planner lane only | allowed only when no-model lacks completion | +| `codex-fast` | bounded repository edits | bounded builders in workset/worktree | must have owned paths + validator | +| `fixture-valid` | deterministic fixtures + dry-run checks | validator/test scaffolding | must pass command contracts | +| `api-openai` | local/non-vision model-assisted calls | explicit API-needed tasks | requires provider/env | +| `api-openai-parallel` | parallel API calls for expensive workloads | optional worker fanout | same gates + cost caps | +| `api-openai-image` | image lane (muted by default for frontends) | screenshot/asset operations | explicit file + screenshot references | +| `api-openai-pro` | GPT Pro fallback path | backend-heavy worksets and hard-proreq | hard-gated and opt-in | + +### 3.2 Model-mode matrix + +For each request, select one primary mode and one fallback mode: + +- `no-model`: default for route discovery, schema check, manifest validation, command/screenshot evidence checks. +- `cheap-model`: only when no-model can’t complete non-subjective parsing (`validation.summary` text, acceptance normalization, short command notes). +- `strong-model`: only for planning and tradeoff decisions. +- `api-openai` / `api-openai-image` / `api-openai-pro`: only when work explicitly requires external model features and gates permit. + +### 3.3 Prohibited behavior + +- strong-model workers must not write into shared repo without a task-bounded lane contract. +- bounded builders may not escape `owned_paths`. +- no-model mode must not invoke model APIs as a required dependency. +- all API model use must produce command/receipt logs and completion evidence. +- model mode cannot hide evidence debt; unresolved checks must remain explicit in `manual_review`. + +## 4) Deterministic-first validation policy + +### 4.1 Principle + +Validation has strict order: + +1. deterministic command checks and structural schema checks, +2. evidence checks for files/URLs/screenshot paths, +3. model-assisted review only if required and explicitly escalated. + +Any unresolved subjective condition is kept as `manual_review`. + +### 4.2 Deterministic checks required by policy + +For each `story.json` + `validation.json` pair: + +- manifest schema fields exist and are valid, +- required command list exists and uses allow-listed commands, +- expected outputs exist by path, +- API fields (when declared) are present and non-empty, +- screenshot entries have deterministic output targets, +- escalation triggers are explicit and machine-readable. + +Validation status mapping: +- `passed`: all deterministic checks pass and required artifacts present. +- `warn`: deterministic checks pass but one or more non-blocking policy constraints missing (e.g., optional screenshot reference missing and lane is muted). +- `manual-review`: ambiguity, high risk, missing manifest references, failed deterministic command. +- `blocked`: hard gate failure with unresolved no-recoverable blockers. + +### 4.3 Escalation criteria to model review + +- `missing_manifest`, +- `high_risk`, +- `failed_deterministic_command`, +- `ambiguous_acceptance`, +- `ux_judgment_required`. + +These must be persisted in `story.validation.escalation_triggers`. + +## 5) Strong-model planning policy + +Strong model is used for: + +- decomposition, +- architecture trade-offing, +- complex plan synthesis, +- risk discovery, +- schema-backed proposal of workstreams. + +Strong-model outputs must follow: + +- no code writes in planning pass, +- produce a plan artifact with bounded scope, +- explicitly name: + - read/write paths, + - validation plan, + - runtime profile, + - run budgets, + - expected artifacts, + - risk and escalation triggers, +- handoff to Codex/validator only after deterministic prechecks are represented. + +If a plan claims write intent, it must include `owned_paths` and `forbidden_paths`. + +## 6) Spark lane split and bounded builders + +### 6.1 Lane contract + +Execution is decomposed and can run in parallel: + +- `planner` lane (strong-model + no-mutate), +- `builder` lanes (codex runtimes + bounded edits), +- `validator` lanes (deterministic-first + screenshot capture), +- `handoff` lane (evidence/package transition). + +Each lane receives a deterministic envelope entry: +- `lane_id`, +- `mode`, +- `runtime_profile`, +- `owned_paths`, +- `read_paths`, +- `max_files`, +- `max_lines`, +- `validator_contract`, +- `cost_usd_hard_cap`. + +### 6.2 Spark/Codex bounded builder constraints + +For every Codex builder task: + +- `read_paths` must be explicit and subset of context, +- `owned_paths` must be explicit and non-empty, +- edits are restricted to owned paths plus task-specific allow-list, +- patch size and file-count bounds apply, +- execution must emit: + - command output, + - modified files list, + - artifact manifests, + - validation artifacts, + - receipt status. + +Failure behavior: +- if bounds are violated, builder fails fast and routes to manual review, +- if evidence artifacts are missing, handoff is blocked with a clear blocker code. + +### 6.3 Lane routing example + +```text +Request -> intent_route -> context_bundle -> strong-model plan (planner) + -> workset/build/factory (builder lane, Codex-fast/isolated, bounded writes) + -> validator lane (deterministic-first) + -> evidence-handoff lane (proof + taskstream) +``` + +## 7) No-model paths + +No-model is the default mode and must remain dominant for these: + +- command and tool discovery (`cento tools`, `cento docs`, `cento runtime list`, `cento scan`), +- registry/tool availability checks, +- schema validation (`story/validation/workset/build/factory schemas`), +- file existence and hash checks, +- API smoke checks where credentials are not required, +- deterministic screenshot capture and evidence indexing, +- dirty-work checks and worktree reconciliation, +- run provenance checks (`live/selected/fallback/cached` provenance where available). + +No-model mode is considered complete only when required outputs are machine-verified, not merely summarized in prose. + +## 8) API, image, and Pro gating policy + +### 8.1 API gating + +Any API runtime lane is only authorized when the command contract explicitly requires it and the feature can be reproduced from manifest. + +- If API keys are missing, fail deterministically with `blocked: missing_env`. +- If required API endpoint schema is absent, fail with `blocked: missing_api_contract`. +- Any API path must emit request/response metadata and status in receipts. + +### 8.2 Image lane gating + +Image operations are supported only when: + +- source asset/reference exists, +- image task is declared in manifest as muted-required or required, +- provider environment is present, +- generated filenames and outputs are deterministic. + +Failure to meet this is either: + +- `muted` when optional and non-blocking, +- `blocked` when the requirement is explicit and non-optional. + +### 8.3 Pro lane gating + +GPT Pro-capable paths are **opt-in** and use hard env gating. + +- `CENTO_HARD_PROREQ_DISPATCH_PRO=1` must be set for hard Pro routing. +- if not enabled, route deterministically to deterministic fallback artifacts with clear `pro-disabled` reason. +- Pro usage must include: + - explicit reason code, + - intended endpoint target, + - fallback plan. + +## 9) Skill authoring rules for Cento runtime + +All skills must follow these invariants: + +1. **Trigger precision** + Frontmatter and description define exact trigger conditions; avoid broad or fuzzy prompts. + +2. **Tiny control surface** + A skill outputs: which tool to call, which artifact to read/write, and where to hand off. + +3. **No duplicate tool catalogs** + Never re-list full `cento tools` contracts inside skill text. + +4. **No in-skill executable logic** + If a Cento command or MCP tool exists, call it; do not reimplement behavior in the skill body. + +5. **Machine evidence first** + Prefer artifact links, paths, and status fields over prose summaries. + +6. **Reference-heavy design** + Move long rationale to `references/*.md`; keep `SKILL.md` minimal and action-driven. + +7. **Explicit proof boundaries** + Each skill defines what counts as success and which checks are `manual_review`. + +8. **Model discipline** + State the required model/runtime mode and include hard limits (`max_cost_usd`, `max_runtime_ms`, `max_patches`, `max_files`). + +9. **Recovery semantics** + Include deterministic fallback path when model/API is unavailable. + +## 10) Canonical acceptance criteria for this lane + +- `cento-native` remains short and discovery-first. +- proposed skills are thin wrappers around CLI/MCP surfaces. +- deterministic checks remain the first gate and are explicit in schema. +- strong models are allowed only for planning and synthesis, never as silent mutation. +- bounded builders require explicit owned paths and receipts. +- API/image/Pro gates are hard and auditable. +- no-model gets default preference for validation and evidence checks. + +## Delivered + +- Captured a complete canonical policy model linking current implementation state and proposed futures. +- Added a concrete skill taxonomy with `cento-native`, `cento-requirements-manifest`, `cento-ai-run`, `cento-validator`, `cento-evidence-handoff`. +- Added runtime policy including deterministic-first validation ordering and no-model-first mode selection. +- Defined Spark/Codex builder boundaries with hard constraints and lane contracts. +- Defined API/image/Pro gating and explicit fallback semantics. +- Added skill authoring standards that keep skills thin and tool-driven. + +## Validation + +- Non-model validation mode for this lane remains file-surface limited. +- Required section exists in this file only: + - `current skill guidance`, + - `proposed thin skills`, + - `model/runtime policy`, + - `deterministic-first validation`, + - `strong-model planning`, + - `Spark/Codex bounded builders`, + - `no-model paths`, + - `API/image/Pro gating`, + - `skill authoring rules`. +- No direct command execution was required to produce this draft beyond file reads. + +## Evidence + +- Backed by: + - run-level validation findings in this execution set (existing matrix and lane artifacts), + - current lane story metadata for expected outputs, + - `docs/ai-cento-native-rework-research.md` assertions (both current state and proposed target), + - existing installed-skill evidence from local environment. + +## Residual risk + +- The proposed MCP tool set and three thin skills are not yet installed in the environment. +- Some policy constants (`CENTO_HARD_PROREQ_DISPATCH_PRO` semantics, API contract IDs, and exact openai-runtime names) must be normalized in implementation. +- Validation of evidence-handoff fields depends on downstream Taskstream conventions still being finalized. +- The runtime policy references explicit cost and patch budgets; those may need calibration after first bounded pilot run. + +--- + +## Spark Coordination Runbook + +_Integrated from `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/spark_coordination.md`._ + +# Spark Lane 5 Coordination Runbook + +This runbook is the operational control playbook for Spark Lane 5 in the execution plan run. +It uses the live Cento CLI surfaces only; the removed pool-dispatch subcommand is not used. + +## Scope and runtime contract + +- Taskstream issues must be created from a valid `story.json` where `issue.id = 0`. +- Spark live dispatch must use `gpt-5.3-codex-spark` for Codex-based runs. +- Live worker fan-out is bounded to **4 active workers** per dispatch batch. +- `agent_pool_kick.py` is used for batch dispatch; it supports `--dry-run` but has **no** `--json` mode. + +## 1) Taskstream issue creation and intake + +Create each issue first, then validate and gate before dispatch. + +```bash +cd /home/alice/projects/cento +python3 scripts/agent_work.py create \ + --title "Spark Lane 5: ... concise scope ... " \ + --manifest workspace/runs/agent-work//story.json \ + --role coordinator \ + --package \ + --owns "" \ + --json +``` + +Notes: +- `create` fails unless `story.json` validates and has `issue.id = 0` (it is rewritten to real issue id automatically). +- For a multi-task package, prefer `agent-work split` for explicit bounded tasks before dispatch. + +## 2) Pre-dispatch validation on the issue + +Preflight every candidate story before queueing it. + +```bash +python3 scripts/agent_work.py preflight \ + workspace/runs/agent-work//story.json \ + --validation-manifest workspace/runs/agent-work//validation.json \ + --write-validation-draft \ + --json +``` + +- If preflight fails, block the item and write the blocker reason on issue before any dispatch. + +```bash +python3 scripts/agent_work.py update \ + --status blocked \ + --role coordinator \ + --note "Preflight failed: ... exact reason ..." +``` + +## 3) Controlled dry-run dispatch plan + +Use this before every live Spark wave. It must pass first. + +```bash +cd /home/alice/projects/cento +CENTO_AGENT_RUNTIME=codex \ +CENTO_POOL_CODEX_MODEL=gpt-5.3-codex-spark \ +CENTO_POOL_STRONG_VALIDATOR_MODEL=gpt-5.3-codex-spark \ +python3 scripts/agent_pool_kick.py \ + --dry-run \ + --max-launch 4 \ + --validator-target 3 \ + --builder-target 4 \ + --small-target 3 \ + --coordinator-target 1 +``` + +- `--max-launch 4` enforces the live cap for this pass. +- Review stdout and `~/.local/state/cento/agent-pool-kick-latest.json` for: + - `reason_summary.primary_reason` + - `reason_summary.summary` + - `reason_summary.next_action` + - `reason_summary.lanes[].lane` + - `reason_summary.lanes[].queued` + - `reason_summary.lanes[].reason` +- If `launched` is empty or blockers exist, resolve blockers first and rerun dry-run. + +## 4) Live dispatch + +Run the live wave only after dry-run is accepted. + +```bash +cd /home/alice/projects/cento +CENTO_AGENT_RUNTIME=codex \ +CENTO_POOL_CODEX_MODEL=gpt-5.3-codex-spark \ +CENTO_POOL_STRONG_VALIDATOR_MODEL=gpt-5.3-codex-spark \ +python3 scripts/agent_pool_kick.py \ + --max-launch 4 \ + --validator-target 3 \ + --builder-target 4 \ + --small-target 3 \ + --coordinator-target 1 +``` + +For a single issue manual override, dispatch directly: + +```bash +python3 scripts/agent_work.py dispatch \ + --role builder \ + --runtime codex \ + --model gpt-5.3-codex-spark +``` + +You may dry-run the same single-item dispatch with `--dry-run` before executing. + +## 5) Monitoring loop + +Use this sequence while the wave is active. + +```bash +python3 scripts/agent_work.py runs --json --active --reconcile --no-untracked +python3 scripts/agent_work.py list --json +python3 scripts/agent_work.py show +``` + +- `runs --active --reconcile --no-untracked` is the primary view of active/queued/stale session state. +- `~/.local/state/cento/agent-pool-kick-latest.json` is the authoritative last-wave dispatch transcript. +- For any abnormal run_id, drill into it: + +```bash +python3 scripts/agent_work.py run-status --json --reconcile +``` + +Repeat the dry-run/live pair when queued capacity remains and active count is below target: + +```bash +python3 scripts/agent_work.py runs --json --active --no-untracked +``` + +Use `--max-launch 4` again on every live re-kick. + +## 6) Stale run handling + +Stale handling is lane-level only after reconciliation and confirmation. + +```bash +python3 scripts/agent_work.py runs --json --active --reconcile --no-untracked +python3 scripts/agent_work.py recovery-plan --json +``` + +- Stale candidates usually appear under: + - `status = stale` + - or `health = stale_no_process` from `run-status` +- If stale dispatch is caused by old Spark dispatch metadata, re-queue only after the issue has no active live owner: + +```bash +python3 scripts/agent_work.py update \ + --status queued \ + --role \ + --note "Old Spark dispatch can be requeued after stale reconciliation." +``` + +- For non-external blockers (`split-needed`, internal artifacts, evidence gaps), follow `recovery-plan` outputs and apply only bounded safe actions. + +```bash +python3 scripts/agent_work.py recovery-plan --apply --json +``` + +- Use `review-drain --dry-run` before apply if a high-risk closure decision is pending. + +## 7) Integration and review closure + +Once per package, drain review-ready approvals before finalizing package closure. + +```bash +python3 scripts/agent_work.py review-drain \ + --package \ + --status review \ + --note "Spark lane review drain preflight." \ + --dry-run + +python3 scripts/agent_work.py review-drain \ + --package \ + --status review \ + --note "Spark lane review drain applied by coordinator." \ + --apply +``` + +For completed items, move to done explicitly: + +```bash +python3 scripts/agent_work.py update \ + --status done \ + --role coordinator \ + --note "Spark lane complete and validated." +``` + +## 8) Closure conditions + +- `python3 scripts/agent_work.py runs --json --active --no-untracked` returns zero active/stale items. +- `python3 scripts/agent_work.py list --json` shows no unexpected Review/Blocked in scope of this package. +- `python3 scripts/agent_work.py recovery-plan --json` reports no residual unsafe blockers. +- Final evidence references are linked in corresponding `story.json` and any handoff/review artifacts. +- Commit any coordination notes and close the Spark coordination issue only after the above checks are true. + +--- + +## Validator Checklist + +_Integrated from `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/validator_review.md`._ + +# Validator Checklist for the Canonical AI-Centric Rework Plan + +**Lane:** Spark 8 (validator) +**Issue:** 1000187 +**Run directory:** `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z` + +## Delivered +- Independent, deterministic validation checklist for the final canonical Markdown merge. +- No edits to source canonical content; this file is a validator draft only. + +## Validation Inputs +- Source research: `docs/ai-cento-native-rework-research.md` +- Section drafts in this run: `workspace/runs/ai-cento-native-execution-plan/20260505T051319Z/sections/*.md` +- Candidate canonical output path (set by coordinator): `CANONICAL_DOC` +- Runtime checks run from repo root: `/home/alice/projects/cento` + +Set before validation: + +```bash +export RUN_DIR=/home/alice/projects/cento/workspace/runs/ai-cento-native-execution-plan/20260505T051319Z +export CANONICAL_DOC=${CANONICAL_DOC:-/home/alice/projects/cento/docs/ai-cento-native-execution-plan.md} +cd /home/alice/projects/cento +mkdir -p "$RUN_DIR/validation" +``` + +## Required Facts to Verify + +### A. Canonical document integrity and traceability +1. Canonical path is resolvable and non-empty. +2. Canonical doc includes explicit scope (research findings + proposed architecture + execution plan). +3. Canonical doc states that the baseline source is `docs/ai-cento-native-rework-research.md` with date/signature context. +4. Canonical doc is assembled from all lane outputs (or states replacements with rationale where one lane owned sections are intentionally merged/renamed). +5. Every non-empirical recommendation is tagged as proposal vs current-state fact. + +### B. Claimable system facts (must be evidence-backed) +6. Tool registry facts (counts, families, source-of-truth references) match a live check at validation time. +7. MCP coverage facts match live tool set (`--list-tools`) and indicate exact parity/gap deltas vs desired surface. +8. Runtime/profile facts align to actual `cento runtime list --json`. +9. Dev Pipeline run facts align to `workspace/runs/dev-pipeline-studio/docs-pages/latest/...` artifacts and `execution_run.json`. +10. Validation/proof semantics are described as source-dependent and the canonical doc includes the mapping used for each run source. +11. Hard-proreq evidence/state claims are tied to artifact paths and are explicitly marked stale/history when not current. +12. Required residual risks and acceptance gates are included as executable criteria (not prose-only). + +## Commands to Run (deterministic checks) + +1. **Precondition checks** + +```bash +python3 - <<'PY' +from pathlib import Path +p = Path('$CANONICAL_DOC') +if not p.exists(): + raise SystemExit(f'CANONICAL_DOC missing: {p}') +print(f'CANONICAL_DOC={p}') +text = p.read_text() +print(f'bytes={p.stat().st_size}') +print(f'headings={sum(1 for line in text.splitlines() if line.startswith("#"))}') +PY + +rg -n '^##|^###|^#' "$CANONICAL_DOC" | head +[ -f "$RUN_DIR/sections/validation_matrix.md" ] +[ -f "$RUN_DIR/sections/target_architecture.md" ] +[ -f "$RUN_DIR/sections/interfaces.md" ] +[ -f "$RUN_DIR/sections/dev_pipeline_gaps.md" ] +[ -f "$RUN_DIR/sections/spark_coordination.md" ] +``` + +2. **Section-to-doc traceability** + +```bash +rg -n "^#|^##|^###" "$RUN_DIR/sections/validation_matrix.md" "$RUN_DIR/sections/target_architecture.md" "$RUN_DIR/sections/interfaces.md" "$RUN_DIR/sections/dev_pipeline_gaps.md" "$RUN_DIR/sections/spark_coordination.md" > "$RUN_DIR/validation/section_headings.txt" +wc -l "$RUN_DIR/validation/section_headings.txt" +``` + +3. **Current-state facts from live tools** + +```bash +cento gather-context --no-remote > "$RUN_DIR/validation/gather_context_now.txt" +python3 - <<'PY' +from pathlib import Path +text = Path('$RUN_DIR/validation/gather_context_now.txt').read_text() +print('total_tools=', 'tools:' in text) +# Keep command output parseable and explicit; manual reviewer should confirm final values. +PY + +cento tools > "$RUN_DIR/validation/cento_tools_now.txt" +cento runtime list --json > "$RUN_DIR/validation/runtime_profiles_now.json" +CENTO_MCP_READ_ONLY=1 python3 scripts/cento_mcp_server.py --list-tools > "$RUN_DIR/validation/mcp_tools_now.json" +``` + +4. **Dev Pipeline and hard-proreq evidence checks** + +```bash +python3 - <<'PY' +import json +from pathlib import Path +base = Path('workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq') +latest = base / 'latest' +exec_run = json.loads((latest/'execution_run.json').read_text()) +print('latest_run_id=', exec_run.get('run_id')) +print('artifact_count=', len(exec_run.get('artifacts', []))) +print('proof=', exec_run.get('proof')) +print('validation=', exec_run.get('validation')) +PY + +[ -f workspace/runs/scan-onepager/latest/summary.json ] && cat workspace/runs/scan-onepager/latest/summary.json | jq . +``` + +5. **Static source checks tied to claims** + +```bash +rg -n "C0[1-9]|C[1-9][0-9]" "$CANONICAL_DOC" "$RUN_DIR/sections/validation_matrix.md" > "$RUN_DIR/validation/claim_ids.txt" +rg -n "proposed|outdated|unknown|partially true|false" "$CANONICAL_DOC" > "$RUN_DIR/validation/claim_statuses.txt" +rg -n "latest run|run_id|artifacts|proof|validation|evidence" "$CANONICAL_DOC" > "$RUN_DIR/validation/primary_claim_lines.txt" +``` + +6. **Cross-check that canonical status transitions are normalized** + +```bash +rg -n "queued|running|completed|failed|manual-review|warning|muted|proof|proof_status|status" "$CANONICAL_DOC" "$RUN_DIR/sections/target_architecture.md" "$RUN_DIR/sections/dev_pipeline_gaps.md" +``` + +## Stale/outdated-command and stale-fact checks + +- Validate that any date-specific claim in the canonical doc is bounded with a timestamp. +- Check if the canonical date differs from run timestamp `2026-05-05T05:13:19Z` and is explicitly called “historical” when older. +- If a command appears in text, verify there is a corresponding captured output artifact timestamped no later than canonical run date. +- If command outputs include tool counts, run IDs, stage counts, validator counts, or artifact counts, verify against fresh outputs captured in this validation session. +- Mark as **STALE** if: no freshness marker, run id/path missing, output artifact absent, or numbers differ without rationale note. + +```bash +rg -n "Date:|as of|as-of|last checked|latest|2026-05-" "$CANONICAL_DOC" +rg -n "1514|223|1286|45|50|23|13|9|37|4-stage|6 stage|runtime profiles" "$CANONICAL_DOC" +``` + +## Acceptance Criteria + +1. **Coverage completeness** + - Canonical doc includes all required domains: baseline state, evidence truth, architecture, lane split, interface changes, proof/validation model, roadmap, risks, and acceptance tests. +2. **Evidence sufficiency** + - Every hard claim is linked to either command output, JSON artifact, source file, or test artifact. +3. **Freshness compliance** + - All command-derived facts either point to fresh outputs from this run window or are explicitly marked as historical baselines. +4. **No ambiguous status semantics** + - Proof/validation statuses are normalized and mapped consistently across claims (configured/queued/running/completed/passed/failed/manual-review/warning/muted/missing). +5. **Lane independence** + - Validator section can reproduce checks without relying on another lane’s prose assumptions. +6. **Actionability** + - Final section ends with machine-checkable pass/fail states and follow-up tasks. + +## Residual Risk Categories + +1. **Environment drift risk**: live tools differ between validation and authoring environments (e.g., tool counts, run IDs). + - Mitigation: include exact command artifacts and re-run commands at handoff. +2. **Canonical-merging drift**: merged document silently drops or rephrases lane sections in a way that loses mandatory constraints. + - Mitigation: preserve section-level source mapping and include one-to-one trace links in final doc. +3. **Stale-baseline leakage**: historical IDs/metrics copied without timestamp markers. + - Mitigation: classify all dated data as `historical` unless revalidated. +4. **Status-canonical mismatch**: status labels not matching underlying execution payloads. + - Mitigation: require one proof-source normalization reference in doc and runbook. +5. **Review bottleneck risk**: validation checks list only positive outcomes. + - Mitigation: require explicit failed/blocked/uncertain examples to demonstrate error paths. + +## Final Pass/Fail Rubric + +- **PASS** + - All required facts verified. + - No stale commands without explicit historical labeling. + - Traceability matrix complete for every factual statement. + - No unresolved escalation triggers and risk list closed as mitigated. + +- **CONDITIONAL PASS** + - One or more claims are marked historical but justified with reproducible rationale and no critical gaps. + - Some non-blocking unknowns remain with follow-up ticket IDs and owners. + +- **FAIL** + - Missing canonical file or required evidence artifacts. + - Unsupported or unverifiable command-derived claims. + - Contradictory proof/validation semantics. + - Absence of lane-8 acceptance criteria or residual-risk closure notes. + +## Evidence + +- A canonical pass requires these files to exist, be non-empty, and be referenced by the final review log: + +```bash +for p in \ + "$RUN_DIR/validation/section_headings.txt" \ + "$RUN_DIR/validation/gather_context_now.txt" \ + "$RUN_DIR/validation/cento_tools_now.txt" \ + "$RUN_DIR/validation/runtime_profiles_now.json" \ + "$RUN_DIR/validation/mcp_tools_now.json" \ + "$RUN_DIR/validation/claim_ids.txt" \ + "$RUN_DIR/validation/claim_statuses.txt" \ + "$RUN_DIR/validation/primary_claim_lines.txt" \ + "$RUN_DIR/validation/section_headings.txt"; do + [ -s "$p" ] || (echo "missing-or-empty $p" && exit 1) +done +``` + +## Evidence Snapshot Bundle (this lane) + +- `$RUN_DIR/validation/section_headings.txt` +- `$RUN_DIR/validation/gather_context_now.txt` +- `$RUN_DIR/validation/cento_tools_now.txt` +- `$RUN_DIR/validation/runtime_profiles_now.json` +- `$RUN_DIR/validation/mcp_tools_now.json` +- `$RUN_DIR/validation/claim_ids.txt` diff --git a/docs/ai-cento-native-rework-research.md b/docs/ai-cento-native-rework-research.md new file mode 100644 index 0000000..5dc0fa6 --- /dev/null +++ b/docs/ai-cento-native-rework-research.md @@ -0,0 +1,968 @@ +# AI and Cento-Native Rework Research + +Date: 2026-05-04 + +Scope: inspect the live Dev Pipeline Studio Execution Flow, research Cento's current agent/skill/tool surfaces end to end, compare against adjacent local AI and pipeline projects, and propose how to make AI work more effective by making Cento the native execution and evidence substrate. + +Primary live surface checked: + +- URL: `http://127.0.0.1:47910/dev-pipeline-studio#pipeline-flow` +- Screenshot artifact: `workspace/runs/ai-cento-native-rework/dev-pipeline-flow-20260504.png` +- API state inspected: `GET /api/dev-pipeline-studio` +- Latest visible run at inspection time: `hard-proreq-task-hard-proreq-project-20260504T065328911797Z` + +## Executive Summary + +Cento already has the most important building blocks for a stronger AI operating model: registered tools, MCP, Taskstream, story manifests, worksets, structured OpenAI workers, deterministic validation, evidence bundles, and a live Dev Pipeline Studio. The missing piece is not another general "agent prompt". The missing piece is a unified Cento-native AI loop where every AI request is converted into a typed contract, routed through Cento tools/MCP first, executed in bounded lanes, validated deterministically, and handed back through Taskstream/evidence artifacts. + +The current hard-proreq Execution Flow is a good prototype of that direction. It captures operator input, generates Cento context, keeps the frontend screenshot lane muted, prepares a schema-backed GPT Pro backend request, materializes backend work, writes integration and validation plans, and leaves evidence artifacts. The page proves that Cento can become the AI control plane rather than just a wrapper around models. + +However, the current system is still split across too many partially overlapping surfaces: + +- Skills tell Codex to route through Cento, but MCP does not yet expose the full Dev Pipeline / Build / Workset / Factory contract surface. +- Dev Pipeline Studio has structured artifacts, but the validator/proof UI is not fully aligned with the underlying receipts. +- `scripts/agent_work_app.py` owns server routes, pipeline defaults, UI state shaping, hard-proreq execution, artifact mapping, and API handlers in one large module. +- The AI lifecycle has multiple schema families (`story.json`, `validation.json`, `execution_run.json`, workset manifests, OpenAI artifacts, evidence bundles) without one top-level run envelope. +- The strongest existing pattern, "deterministic-first validation", exists in docs and tooling but should become the default mechanical gate for every AI run. + +The recommendation is to rework AI around a Cento AI Runtime made of five layers: + +1. Intent router: classify the user request and choose the registered Cento path. +2. Context builder: gather Cento context, code ownership, dirty state, route/API targets, and validation candidates. +3. Contract planner: create a typed story/workset/pipeline contract before model work. +4. Execution lanes: run model/tool workers only inside declared ownership, budget, and runtime constraints. +5. Evidence gate: validate with deterministic receipts, screenshots, logs, and Taskstream handoff. + +Skills should become short routing adapters into this runtime, not the runtime itself. + +## Research Method + +Commands and surfaces used: + +- `cento gather-context --no-remote` +- `cento tools` +- `cento docs agent-work` +- `cento docs build` +- `cento docs factory` +- `cento docs cento-mcp` +- `cento runtime list --json` +- `python3 scripts/cento_mcp_server.py --list-tools` +- `cento scan --query "agent-work" --no-open` +- `npx --yes playwright screenshot --browser=firefox --full-page http://127.0.0.1:47910/dev-pipeline-studio#pipeline-flow /tmp/dev-pipeline-flow.png` +- Direct API checks against `GET /api/dev-pipeline-studio` +- Direct artifact reads under `workspace/runs/dev-pipeline-studio/docs-pages/latest/` + +The `cento scan --query "agent-work"` run scanned 1514 Cento source files, found 223 matched files and 1286 matches, and wrote the latest scan artifact at `workspace/runs/scan-onepager/latest/summary.json`. + +## Code Places Inspected + +The request asked for at least five code places in different projects. I inspected six project/surface families: + +1. Cento core: + - `scripts/agent_work_app.py` + - `scripts/dev_pipeline_hard_proreq.py` + - `scripts/cento_workset.py` + - `scripts/cento_openai_worker.py` + - `scripts/cento_mcp_server.py` + - `scripts/agent_work.py` + - `scripts/story_manifest.py` + - `data/tools.json` + - `docs/agent-work.md` + - `docs/agent-work-story-manifest.md` + - `docs/agent-work-validator-lane.md` + - `docs/dev-pipeline-run-contracts.md` + - `tests/test_dev_pipeline_delivery.py` + +2. Codex/Cento skills: + - `/home/alice/.codex/skills/cento-native/SKILL.md` + - `/home/alice/.codex/skills/cento-native/references/routing.md` + - `/home/alice/.codex/skills/cento-requirements-manifest/SKILL.md` + - `/home/alice/.codex/skills/.system/skill-creator/SKILL.md` + +3. GitHub plugin skills: + - `/home/alice/.codex/plugins/cache/openai-curated/github/3c463363/skills/gh-address-comments/SKILL.md` + - `/home/alice/.codex/plugins/cache/openai-curated/github/3c463363/skills/yeet/SKILL.md` + +4. OpenCode: + - `/home/alice/projects/opencode/packages/opencode/src/tool/tool.ts` + - `/home/alice/projects/opencode/packages/opencode/src/tool/task.ts` + - `/home/alice/projects/opencode/packages/opencode/src/session/system.ts` + - `/home/alice/projects/opencode/packages/opencode/src/session/mode.ts` + - `/home/alice/projects/opencode/packages/opencode/src/config/config.ts` + +5. Docmgmt RAG project: + - `/home/alice/projects/docmgmt/build_index.py` + - `/home/alice/projects/docmgmt/generate_letter.py` + +6. AIPOC pipeline project: + - `/home/alice/projects/aipoc/airflow/dags/ml_training_pipeline.py` + +## Live Dev Pipeline Studio Observations + +The live page is already close to the desired mental model. It shows: + +- Product shell: Cento Console -> Software Delivery Hub -> Dev Pipeline Studio -> Execution Flow. +- Active route: hard-proreq route. +- Run status: completed. +- Source: `cento-hard-proreq-pro`. +- Runtime: `cento-native + GPT pro request`. +- Run mode: `backend-plan-first`. +- Stage count: 4 displayed high-level cards in the UI, backed by 6 stage records in the API. +- Step count: 9 execution steps. +- Artifact count: 23 in API state, with 13 existing hard-proreq artifacts in the run payload summary. +- Run history: 9 hard-proreq runs shown in the API. + +The latest run artifacts include: + +- `operator_intake.json` +- `mini_cento_context.json` +- `ui_screenshot_request.json` +- `existing_ui_reference.png` +- `image_generation_request.json` +- `image_generation_response.json` +- `pro_output_schema.json` +- `pro_backend_request.json` +- `pro_backend_plan.json` +- `backend_work_manifest.json` +- `integration_plan.json` +- `validation_plan.json` +- `hard_proreq_evidence.json` + +The hard-proreq run path proves a useful E2E concept: + +1. Capture the operator prompt and questionnaire answer. +2. Build a mini Cento context artifact. +3. Split frontend screenshot work into a muted lane. +4. Prepare a strict schema-backed backend planning request. +5. Dispatch or simulate GPT Pro planning. +6. Convert the backend plan into Cento-native workstreams. +7. Write integration and validation plans. +8. Collect a hard-proreq evidence artifact. +9. Render the whole thing in Execution Flow. + +## Current Strengths + +### Cento Already Has A Native Tool Contract + +`cento gather-context --no-remote` reports 45 registered tools and explicitly says `data/tools.json`, `cento tools`, `cento platforms`, and `cento docs` are the source of truth. This is exactly the right foundation for AI routing. An AI should not invent shell commands when Cento already knows the durable tool contract. + +Important registered surfaces: + +- `agent-work`: Taskstream-backed task creation, splitting, dispatch, prompt, runs, and validation. +- `build`: manifest-owned local build primitive with owned path checks, worker prompts, patch bundles, dry-run integration, and safe apply. +- `factory`: deterministic planning, dispatch dry-runs, patch collection, Safe Integrator, release evidence, and Autopilot. +- `workset`: N-worker runner with exclusive write paths, dependencies, structured API workers, and sequential integration. +- `cento-mcp`: structured MCP surface for context, agent-work, story, cluster, bridge, and platform operations. +- `scan`: archived local source scan one-pagers. +- `runtime`: validation of local builder runtime profiles. + +### Skills Already Point In The Correct Direction + +The `cento-native` skill says to treat Cento as the source of truth before inventing scripts, tools, registries, workflows, or cross-node commands. Its routing order is also correct: + +1. MCP tools when structured Cento MCP exists. +2. Registered CLI tools from `cento tools` / `data/tools.json`. +3. Existing aliases. +4. Existing scripts only after confirming the registered entrypoint. +5. New code only when discovery shows no existing path fits. + +That should become the default AI posture across all Cento tasks. + +### Deterministic-First Validation Is Explicit + +The Agent Work docs say validation is no-model by default. Builders leave durable artifacts, validators prefer files, commands, URLs, screenshots, generated reports, and review summaries over model judgment. Story manifests carry `validation.mode`, `risk`, `no_model_eligible`, `escalation_triggers`, and commands. + +This is important: it prevents "the model says it is done" from becoming the validation boundary. + +### Workset And OpenAI Workers Are Well-Bounded + +`scripts/cento_workset.py` rejects overlapping write paths and requires explicit write paths. It supports dependencies, structured API workers, budgeted execution, patch materialization, and sequential integration. This is the right mechanical substrate for AI code work. + +`scripts/cento_openai_worker.py` is also directionally strong: + +- It produces structured artifacts only. +- It never mutates repository files. +- It defines explicit output schemas such as `patch_proposal.v1`, `validation_review.v1`, `workset_plan.v1`, and `hard_proreq_plan.v1`. +- It validates structured outputs before producing receipts. + +### MCP Exists, But Needs A Wider Surface + +`scripts/cento_mcp_server.py` exposes context, platforms, cluster status, bridge status, agent-work list/show/create/claim/update/handoff/validate, and story manifest validate/render. It constrains file paths to the Cento repo and supports read-only mode through `CENTO_MCP_READ_ONLY`. + +This should be the AI's first structured interface. The gap is that Dev Pipeline Studio, Build, Workset, Factory, runtime profiles, scan, and evidence queries are not yet first-class MCP tools. + +### OpenCode Shows Useful Agent Mode Patterns + +OpenCode's source separates: + +- Tool definitions via typed `Tool.define(...)`. +- A `task` tool that starts a subordinate session with restricted tools. +- System prompt context that loads project instructions from `AGENTS.md`, `CLAUDE.md`, and configured instruction globs. +- Modes that can disable write/edit/patch tools for planning. +- MCP configuration as typed local/remote schemas. + +Cento can adopt the same idea at a higher level: separate `plan`, `build`, `validate`, `handoff`, and `review` modes as runtime policies, not just prompt instructions. + +### The Docmgmt RAG Project Shows The Need For Safer Retrieval + +The docmgmt project has a simple RAG loop: build a FAISS index from local documents, retrieve relevant chunks, and generate a letter with a prompt template. The pattern is useful because Cento also needs local context retrieval, but the implementation shows risks Cento should avoid: + +- Retrieval is not tied to durable citations. +- There is no output schema. +- FAISS loading uses `allow_dangerous_deserialization=True`. +- The final output is written directly without validation or evidence receipts. + +Cento should take the good part, local retrieval, and wrap it in safe indexes, citations, schemas, and validation. + +### The Airflow DAG Shows A Clear Pipeline Mental Model + +The AIPOC DAG is simple but useful as a comparison: `load_data -> train_and_validate`, with MLflow metrics. Cento should make every AI pipeline similarly explicit: + +- Named task nodes. +- Declared dependencies. +- Durable outputs. +- Metrics and receipts. +- A visible run graph. + +Dev Pipeline Studio is already moving in that direction. + +## Key Findings + +### Finding 1: The Hard-Proreq Route Is The Right Strategic Direction + +The hard-proreq route is the first place where Cento behaves like an AI-native platform: + +- User input becomes a typed input manifest. +- Cento context is generated before model planning. +- Frontend visual work is explicitly separated and muted. +- GPT Pro planning is schema-backed. +- Backend work becomes Cento workstreams with owned paths, dependencies, validation commands, and handoff artifacts. +- Evidence is collected as files, not just as chat. + +This is the pattern to generalize. + +### Finding 2: Skills Are Currently Guidance, Not Execution Contracts + +Skills are useful for steering Codex, but they should not be the final source of truth for AI execution. A skill can be ignored, partially remembered, or overloaded by conversation context. Cento tools and MCP calls are more reliable because they return structured state and write durable artifacts. + +Recommended posture: + +- Skills route the agent to Cento. +- Cento creates and validates the execution contract. +- The agent follows the contract. +- Evidence decides whether work is done. + +### Finding 3: MCP Is Too Narrow For The Desired AI Loop + +Current MCP is strong for agent-work and story manifests, but the desired E2E loop also needs structured tools for: + +- Dev Pipeline Studio state. +- Starting a pipeline run. +- Reading execution run artifacts. +- Running `cento scan`. +- Checking runtime profiles. +- Creating build manifests. +- Executing worksets. +- Reading Factory plan/status/evidence. +- Querying evidence completeness. + +Without these MCP tools, Codex falls back to shell and ad hoc API calls. That works for a power user, but it is less reliable as an AI substrate. + +### Finding 4: Execution Flow Proof And Validation Are Not Fully Aligned + +The live page showed "Receipt pending" in the Proof panel even though the hard-proreq run had completed and written `hard_proreq_evidence.json`. The Validation Results panel showed `0 / 3 validators passed` and listed validators as configured, while the template-level hard-proreq validators in the manifest are `passed`, `passed`, and `muted`. + +This is not just a UI polish issue. It means the AI operator cannot fully trust the visual control plane as the source of truth. The Execution Flow should derive proof status from the correct receipt type for each run source: + +- Workset runs: `workset_receipt`. +- Hard-proreq runs: `hard_proreq_evidence.json`, `validation_plan.json`, and validator receipts. +- Factory runs: Factory integration/release receipts. +- Build runs: integration/apply/evidence receipts. + +### Finding 5: The Code Has One Monolithic Control-Plane Module + +`scripts/agent_work_app.py` currently handles static serving, Taskstream API handlers, Dev Pipeline Studio state, hard-proreq defaults, pipeline run validation, execution threading, artifact mapping, UI state shaping, and route handling. That makes it harder to evolve AI runtime behavior safely. + +This is manageable now but will become a bottleneck if Dev Pipeline Studio becomes the main AI run surface. + +### Finding 6: Cento Has Multiple Manifest Families Without One Run Envelope + +There are strong schemas, but they live in parallel: + +- `story.json` +- `validation.json` +- `deliverables.json` +- `pipeline_manifest.json` +- `execution_run.json` +- `workset.json` +- `cento.api_worker_artifact.v1` +- `cento.workset_receipt.v1` +- `hard_proreq_evidence.json` + +The missing abstraction is a top-level `cento.ai_run.v1` envelope that links all of them and normalizes status, owner, scope, model usage, artifacts, receipts, validation, and next action. + +### Finding 7: Runtime Profiles Are Promising But Underused + +The current runtime registry has `codex-fast`, `fixture-valid`, and `python-fixture`. `codex-fast` is a command runtime using `codex exec --prompt-file {prompt}`, with timeout, patch size, changed-file limits, and network disabled. + +That is the right shape. The AI rework should make runtime profile selection an explicit part of every contract: + +- `planner`: cheap or strong model, no writes. +- `builder`: Codex runtime in worktree, write-limited. +- `validator`: deterministic commands first, model only if story requires it. +- `docs-evidence`: no product writes except evidence/hub paths. + +### Finding 8: Existing GitHub Plugin Skills Model Good Connector Discipline + +The GitHub plugin skills choose a structured connector first, then use `gh` for gaps such as thread-aware review state. That is the right precedent for Cento: + +- Use Cento MCP for structured state. +- Use Cento CLI for registered durable operations. +- Use raw shell only for local coding/tests/file reads. +- Avoid pretending a flat or incomplete surface is complete. + +### Finding 9: Current Hard-Proreq Pro Dispatch May Be A Deterministic Fallback + +The inspected `pro_backend_plan.json` says: "GPT pro request is schema-ready; backend work uses deterministic fallback until CENTO_HARD_PROREQ_DISPATCH_PRO=1 is enabled." That is a sensible development mode, but the UI language should make clear whether the plan came from live Pro, a deterministic fallback, or a cached prior artifact. + +This matters for trust, cost, and evaluation. + +### Finding 10: AI Effectiveness Needs Evaluation Metrics, Not Just Better Prompts + +The platform should measure: + +- Time from prompt to typed contract. +- Percent of runs with complete story/validation/evidence links. +- Percent of AI runs blocked by missing context, dirty paths, or missing credentials. +- Percent of validations passing without model judgment. +- Rework rate after human review. +- Cost per accepted change. +- Number of runs with ambiguous status or missing receipts. +- Number of direct shell commands used where a Cento/MCP route existed. + +## Proposed AI Architecture + +### Principle + +Make Cento the operating system for AI work. Models should be workers inside Cento contracts, not independent actors that happen to call Cento sometimes. + +### Target Layers + +#### 1. Intent Router + +Input: raw user request, route, screenshot, issue, or code question. + +Output: a typed routing decision. + +Responsibilities: + +- Classify request type: status, research, docs, UI, code change, pipeline run, Taskstream task, validation, release, cluster operation. +- Check whether an existing Cento MCP tool or registered CLI tool handles it. +- Choose whether the request needs Taskstream work or can be executed directly. +- Decide whether model work is needed at all. +- Choose a pipeline template: hard-proreq, generic-task, doc-page, UI screenshot, validation-only, release-evidence, Factory integration. + +Implementation path: + +- Add `cento_intent_route` as an MCP tool and CLI helper. +- Input schema should include `prompt`, `cwd`, `route`, `provided_paths`, `screenshots`, `risk_hint`, and `mode`. +- Output schema should include `route`, `required_context`, `pipeline_template`, `requires_taskstream`, `requires_model`, `requires_human`, `validation_mode`, and `next_command`. + +#### 2. Context Builder + +Input: routing decision. + +Output: context bundle. + +Responsibilities: + +- Run `cento gather-context --no-remote` or full remote context when needed. +- Snapshot dirty work and protected paths. +- Identify registered tools and docs relevant to the route. +- Build code search hits with paths and line references. +- Inspect route/API/UI surface when present. +- Collect existing run artifacts and recent failures. + +Implementation path: + +- Generalize `mini_cento_context.json` into `cento.context_bundle.v1`. +- Add citations: every claim should point to a file path, command output artifact, API artifact, or screenshot. +- Store under `workspace/runs/ai//context_bundle.json`. + +#### 3. Contract Planner + +Input: context bundle and user request. + +Output: execution contract. + +Responsibilities: + +- Create or update `story.json` for work that should enter Taskstream. +- Create `workset.json` for bounded code changes. +- Create `pipeline_manifest` selections for Dev Pipeline Studio. +- Create `validation.json` with deterministic commands and escalation triggers. +- Define ownership, write paths, read paths, routes, budgets, runtime profiles, and acceptance criteria. + +Implementation path: + +- Add a top-level `cento.ai_run.v1` manifest that links all other manifests. +- Require that every builder has explicit `owned_paths` or an explicit "planning only" mode. +- Require that every validation path declares whether it is no-model, cheap-model, strong-model, or human. + +#### 4. Execution Lanes + +Input: execution contract. + +Output: artifacts and receipts. + +Lane types: + +- Context lane: deterministic. +- Planner lane: model allowed, no writes. +- Builder lane: Codex or other runtime in isolated worktree, bounded writes. +- Validator lane: deterministic first, model review only when explicitly required. +- Docs/evidence lane: durable summaries, screenshots, hubs, links. +- Integrator lane: applies only accepted bundles/receipts. + +Implementation path: + +- Route planner/model work through `cento_openai_worker.py` or Codex runtime profiles. +- Route code changes through `cento build` or `cento workset`. +- Route multi-task dispatch through `cento agent-work` or `cento factory`. +- Never let an AI worker both invent scope and mutate the shared worktree in one step. + +#### 5. Evidence Gate + +Input: execution outputs. + +Output: pass/fail/block and handoff. + +Responsibilities: + +- Validate all declared JSON schemas. +- Run deterministic commands. +- Check artifacts exist. +- Check screenshots and UI captures when relevant. +- Produce a review summary. +- Update Taskstream only when the proper lane owns the status transition. + +Implementation path: + +- Make Execution Flow proof source-dependent. +- Use `story_manifest.py validate`, `agent-work validate-run`, and validation manifests for all Taskstream work. +- Add `cento evidence check ` or MCP equivalent. + +## Proposed E2E Flow + +```text +User request + -> cento_intent_route + -> cento_context_bundle + -> cento_ai_run manifest + -> story/workset/pipeline contract + -> preflight + - dirty path check + - protected path check + - platform check + - credential/model check + - budget check + -> model planning if needed + -> bounded execution lane + - build/workset/factory/agent-work + -> deterministic validation + -> evidence bundle + -> Taskstream or direct handoff + -> learning/evaluation record +``` + +This flow should exist whether the user starts from: + +- Chat prompt. +- `/issues/new?prompt=...`. +- Dev Pipeline Studio Run Pipeline. +- Taskstream issue. +- GitHub PR feedback. +- Screenshot plus requirements. +- CLI command. + +## Skill Rework Plan + +### Current Skill Problem + +Skills currently carry valuable instructions, but they do not create durable run state. A skill can guide the agent to do the right thing, but once the agent starts improvising, there is no guaranteed contract or evidence trail. + +### Target Skill Role + +Each Cento skill should answer only: + +- When does this skill trigger? +- Which Cento route should be used? +- Which context/reference file should be loaded? +- Which hard stops apply? +- Which validation evidence is required? + +Everything executable should move into Cento tools, MCP, or scripts. + +### Recommended Skill Set + +#### `cento-native` + +Role: core routing and safety. + +Keep it short. It should say: + +- Start with Cento discovery. +- Prefer MCP. +- Prefer registered tools. +- Use Taskstream for Cento feature/automation changes. +- Use temp/cluster/batch wrappers for one-off work. +- Preserve dirty user work. + +Add references: + +- `references/routing.md`: existing command routing. +- `references/dev-pipeline.md`: how to use Dev Pipeline Studio and `POST /api/pipeline-runs`. +- `references/evidence.md`: story/validation/evidence requirements. +- `references/runtime.md`: model/runtime selection rules. + +#### `cento-requirements-manifest` + +Role: convert screenshots/mockups/rough requirements into pickup-ready contracts. + +Keep hard stops. This skill should not dispatch work. It should produce `cento.requirements_manifest.v1` and, when useful, a draft `story.json`. + +#### `cento-ai-run` + +New role: start a Cento AI run from a prompt. + +This skill should route to: + +- `cento_intent_route` +- `cento_context_bundle` +- `cento_ai_run create` +- Dev Pipeline Studio or workset/build/factory depending on the routing decision. + +It should be thin and mostly point to MCP/CLI. + +#### `cento-validator` + +New role: independent validation lane. + +This skill should: + +- Read `story.json` and `validation.json`. +- Run deterministic checks. +- Capture screenshots when declared. +- Write validator evidence. +- Avoid product code edits unless explicitly asked. + +#### `cento-evidence-handoff` + +New role: package manager-facing outputs. + +This skill should: + +- Build start hubs. +- Summarize evidence. +- Verify artifact links. +- Produce review notes with Delivered, Validation, Evidence, and Residual risk. + +### Skill Authoring Rules + +Use the skill-creator guidance: + +- Keep frontmatter descriptions precise, because descriptions are the trigger mechanism. +- Keep `SKILL.md` lean. +- Move detailed material into references. +- Put deterministic/repeated logic into scripts. +- Do not put broad README-style docs inside skills. + +For Cento specifically: + +- A skill should never duplicate the full `cento tools` contract. +- A skill should name the tool to call and the artifact expected. +- A skill should not carry long code snippets if a Cento command can generate them. + +## MCP Rework Plan + +Add these MCP tools: + +### Read Tools + +- `cento_dev_pipeline_state` + - Inputs: `project_id`, `template_id`, `run_id`. + - Wraps `GET /api/dev-pipeline-studio` or direct state builder. + +- `cento_dev_pipeline_run_show` + - Inputs: `run_id`. + - Returns execution run, artifacts, logs, validation status, and proof status. + +- `cento_scan` + - Inputs: `query`, `case_sensitive`, `no_open`. + - Returns scan summary and artifact path. + +- `cento_runtime_list` + - Returns runtime profiles and validation status. + +- `cento_evidence_check` + - Inputs: `ai_run`, `story`, or `run_dir`. + - Returns missing evidence, stale receipts, failed checks, and next action. + +### Explicit Write Tools + +- `cento_dev_pipeline_run_start` + - Inputs: `project_id`, `template_id`, typed `inputs`. + - Wraps the existing pipeline run API. + +- `cento_ai_run_create` + - Inputs: route decision and context bundle. + - Writes the top-level `cento.ai_run.v1` envelope. + +- `cento_build_init` + - Inputs: task, read paths, write paths, validation tier. + - Writes build manifest and builder prompt. + +- `cento_workset_execute` + - Inputs: workset path, runtime profile, integration mode, validation mode, budget. + - Executes a workset and returns receipt. + +- `cento_factory_plan` + - Inputs: intake text/run dir. + - Creates deterministic Factory plan. + +Write tools should obey the current MCP pattern: + +- Explicit writes only. +- `CENTO_MCP_READ_ONLY=1` disables writes. +- Paths constrained to the Cento repo root. +- Return `ok`, `exit_code`, `command`, `stdout`, `stderr`, and structured artifact paths. + +## Dev Pipeline Studio Rework + +### Keep + +- The hard-proreq route. +- Run-scoped artifacts. +- Strict input contract. +- Muted frontend screenshot lane. +- Schema-backed backend plan. +- Evidence panel. +- Previous run history. +- Per-stage details and logs. + +### Fix + +- Proof panel should understand non-workset receipts. +- Validation Results should use actual validator receipt state, not only configured state. +- The stage card count should match API stages or clearly separate "phases" from "stages". +- The UI should label fallback/cached/live model execution distinctly. +- Artifact list should group artifacts by contract stage. +- The "Run pipeline" button should expose the typed input contract clearly before start. + +### Add + +- "Open run manifest" link. +- "Open evidence bundle" link. +- "Create Taskstream work from this plan" action. +- "Run validator now" action. +- "Promote to workset/build/factory" action. +- "Replay run from artifacts" action. +- "Copy MCP call" and "Copy CLI command" actions for every run. + +## Model And Runtime Strategy + +### Use Strong Models For Planning, Not Unbounded Mutation + +Strong models are valuable for: + +- Ambiguous decomposition. +- Architecture choices. +- Risk discovery. +- Schema-backed planning. +- Review of complex tradeoffs. + +Strong models should not directly mutate the shared worktree. They should produce typed plans, workstreams, prompts, and validation recommendations. + +### Use Codex Runtime Profiles For Bounded Code Work + +Use runtime profiles like `codex-fast` for: + +- Small owned-path code changes. +- Isolated worktree work. +- Patch proposals. +- Tests and logs. + +Every code worker should get: + +- Owned write paths. +- Read paths. +- Acceptance criteria. +- Validation commands. +- Protected paths. +- Budget/time limits. +- Required output artifact schema. + +### Use Cheap Models For Low-Risk Assistance + +Cheap/small models can: + +- Classify intent. +- Summarize context bundles. +- Draft story manifests. +- Draft evidence summaries. +- Suggest validation commands. + +But their output should still be checked by deterministic gates. + +### Use No Model When Possible + +No-model paths should handle: + +- Context gathering. +- Tool lookup. +- Registry checks. +- Schema validation. +- File existence checks. +- API smoke checks. +- Screenshot capture. +- Evidence link checking. +- Worktree dirty checks. + +## Proposed `cento.ai_run.v1` + +This envelope should sit above current manifests. + +```json +{ + "schema_version": "cento.ai_run.v1", + "id": "ai-run-20260504-001", + "source": { + "kind": "chat_prompt", + "url": "http://127.0.0.1:47910/dev-pipeline-studio#pipeline-flow", + "issue_id": "", + "created_at": "2026-05-04T00:00:00Z" + }, + "route": { + "decision": "dev-pipeline-studio", + "project_id": "hard-proreq-project", + "template_id": "hard-proreq-task", + "requires_taskstream": false, + "requires_model": true, + "validation_mode": "no-model" + }, + "context": { + "bundle": "workspace/runs/ai/ai-run-20260504-001/context_bundle.json", + "commands": ["cento gather-context --no-remote", "cento tools"], + "code_refs": [] + }, + "contracts": { + "story": "", + "validation": "", + "pipeline_manifest": "workspace/runs/dev-pipeline-studio/docs-pages/latest/pipeline_manifest.json", + "workset": "" + }, + "execution": { + "status": "completed", + "runtime": "cento-native + GPT pro request", + "model": "gpt-5.4-pro", + "run_id": "hard-proreq-task-hard-proreq-project-20260504T065328911797Z", + "started_at": "", + "finished_at": "", + "cost_usd": 0.0 + }, + "artifacts": [], + "receipts": [], + "validation": { + "status": "passed", + "deterministic_passed": true, + "manual_review_required": false, + "missing": [] + }, + "handoff": { + "status": "ready", + "taskstream_issue": "", + "next_action": "review backend work manifest" + } +} +``` + +Benefits: + +- One run can be inspected by CLI, MCP, UI, and agents. +- Receipts become source-dependent but normalized. +- Taskstream can link to a single run envelope. +- Dev Pipeline Studio can render any AI route, not only hard-proreq and generic task. +- Evaluation metrics can aggregate across run types. + +## Code Structure Improvement + +This is intentionally small and pragmatic. The current code works, but the AI runtime should not keep growing inside one app script. + +### Split `scripts/agent_work_app.py` + +Proposed structure: + +```text +scripts/ + agent_work_app.py # thin HTTP entrypoint and server bootstrap + agent_work_api.py # Taskstream issue/review API handlers + dev_pipeline/ + __init__.py + state.py # dev_pipeline_studio_state and API payload shaping + routes.py # pipeline API request/response handlers + manifests.py # pipeline defaults and manifest normalization + execution.py # seed/spawn/finish execution runs + hard_proreq.py # hard-proreq route integration + validation.py # validator/proof normalization + artifacts.py # artifact URL/path/size logic + schemas.py # schema constants and typed payload helpers +``` + +Keep backward-compatible imports initially so tests can migrate incrementally. + +### Move Hard-Proreq Defaults To Data + +Move hard-proreq project/template/default input definitions out of Python into versioned JSON: + +```text +data/dev-pipeline/templates/hard-proreq-task.json +data/dev-pipeline/projects/hard-proreq-project.json +data/dev-pipeline/schemas/pipeline-run-request.json +``` + +Python should load, validate, and normalize them rather than own all defaults inline. + +### Normalize Status In One Place + +Create one backend status normalizer and export the mapping to frontend: + +- `configured` +- `queued` +- `running` +- `completed` +- `passed` +- `failed` +- `blocked` +- `muted` +- `skipped` +- `accepted` + +Avoid the current mismatch where validators are `passed` in one artifact but displayed as `configured` elsewhere. + +### Make Proof Source-Dependent + +Create a proof resolver: + +```text +resolve_proof(execution_run) -> { + status, + receipt_kind, + receipt_path, + facts, + missing +} +``` + +It should know: + +- `cento-workset-api-openai` -> workset receipt. +- `cento-hard-proreq-pro` -> hard-proreq evidence and validation plan. +- `cento-build` -> build integration/apply/evidence receipts. +- `cento-factory` -> factory integration/release receipts. + +### Add Thin Tests Per Module + +Keep the existing focused tests and add: + +- `test_dev_pipeline_proof_resolver.py` +- `test_dev_pipeline_validation_status.py` +- `test_cento_ai_run_manifest.py` +- `test_cento_mcp_dev_pipeline.py` + +## Roadmap + +### Phase 0: Align Current Execution Flow + +Target: 1-2 days. + +- Fix proof status for hard-proreq. +- Fix validation status mapping. +- Add "live/fallback/cached" model source label. +- Add tests around current hard-proreq latest run state. +- Add `cento_dev_pipeline_state` MCP read tool. + +### Phase 1: Add AI Run Envelope + +Target: 2-4 days. + +- Define `cento.ai_run.v1`. +- Write `cento ai-run create/show/check` CLI. +- Link existing Dev Pipeline runs into the envelope. +- Add evidence completeness checks. +- Render AI run summaries in Dev Pipeline Studio. + +### Phase 2: Widen MCP + +Target: 3-5 days. + +- Add Dev Pipeline read/start tools. +- Add scan/runtime read tools. +- Add evidence check tool. +- Add build/workset write tools with read-only protection. +- Add MCP docs and smoke tests. + +### Phase 3: Rework Skills Around Cento Runtime + +Target: 1-3 days. + +- Keep `cento-native` small. +- Add `references/dev-pipeline.md`, `references/evidence.md`, and `references/runtime.md`. +- Add `cento-ai-run`, `cento-validator`, and `cento-evidence-handoff` skills. +- Ensure each skill points to MCP/CLI, not copied logic. + +### Phase 4: Generalize Hard-Proreq + +Target: 1-2 weeks. + +- Extract hard-proreq into data templates. +- Support generic easy/medium work through the same envelope. +- Let users promote a hard-proreq backend plan into Taskstream/Factory/workset. +- Make screenshot lane optional but visible and auditable. + +### Phase 5: Evaluation Loop + +Target: ongoing. + +- Record run metrics. +- Track validation pass/fail causes. +- Track cost and runtime by route. +- Track rework after human review. +- Track cases where agents bypassed Cento tool routing. + +## Concrete Next Tasks + +1. Add `cento_dev_pipeline_state` MCP read tool. +2. Add proof resolver for hard-proreq execution runs. +3. Fix Validation Results to read real validator receipt/status state. +4. Draft `data/schemas/cento-ai-run.v1.json`. +5. Add `cento ai-run create/show/check` commands. +6. Split Dev Pipeline Studio code out of `agent_work_app.py` behind compatibility wrappers. +7. Add `cento-native/references/dev-pipeline.md`. +8. Add `cento-validator` skill for independent validation lane. +9. Add Dev Pipeline "Create Taskstream work from plan" action. +10. Add evaluation metrics to `hard_proreq_evidence.json` or the new AI run envelope. + +## Bottom Line + +Cento-nativeness is the right direction. The strongest design is not "more skills" or "better prompts" by itself. The strongest design is: + +- skills for lightweight intent routing, +- MCP for structured Cento operations, +- registered CLI tools as durable execution contracts, +- worksets/build/factory for bounded AI work, +- story and validation manifests for acceptance, +- deterministic receipts and screenshots for proof, +- Taskstream for human-visible lifecycle, +- Dev Pipeline Studio for live run observability. + +The current hard-proreq Execution Flow already demonstrates this. The next step is to turn it from a specialized route into the standard Cento AI runtime. diff --git a/docs/ai-cento-native-rework.html b/docs/ai-cento-native-rework.html new file mode 100644 index 0000000..5814e94 --- /dev/null +++ b/docs/ai-cento-native-rework.html @@ -0,0 +1,799 @@ + + + + + + Cento-Native AI Rework + + + +
+ + +
+
+
+ Research digest + Dev Pipeline Studio + 2026-05-04 +
+

Rework AI around Cento as the execution layer

+

Cento already has tools, Taskstream, MCP, worksets, Factory, structured workers, validation, and evidence. The next step is to make every AI request become a typed Cento run instead of a loose chat thread.

+ +
+ +
+
+

30-second summary

+

The current hard-proreq pipeline proves the direction: capture the prompt, gather Cento context, separate UI screenshot work, ask for schema-backed planning, materialize backend work, validate, and preserve evidence. That pattern should become the standard AI runtime.

+
+
Current proof pointHard-proreq route

Live Execution Flow already turns a prompt into staged artifacts and evidence.

+
Main gapContracts split

Story, validation, workset, pipeline, and evidence manifests are not unified by one AI run envelope.

+
Best leverMCP + tools

Agents should prefer Cento MCP and registered CLI tools before shell improvisation.

+
Validation ruleNo-model first

Durable receipts, commands, screenshots, and evidence should decide done state.

+
+
+ Core recommendation +

Build a Cento AI Runtime with five stages: intent router, context builder, contract planner, execution lanes, and evidence gate. Skills should trigger that runtime, not replace it.

+
+
+ +
+

What the live page already proves

+

The inspected run at http://127.0.0.1:47910/dev-pipeline-studio#pipeline-flow was a completed hard-proreq run with nine execution steps and run-scoped artifacts.

+
+
1

Operator intake

Preserves the raw prompt and questionnaire answers before planning starts.

+
2

Cento context

Builds mini_cento_context.json from repo, tools, docs, and task-relevant files.

+
3

Muted UI lane

Separates screenshot generation from backend planning so visual work does not block the run.

+
4

Schema-backed plan

Prepares a GPT Pro backend request and strict output schema for planning.

+
5

Evidence handoff

Writes backend work, integration plan, validation plan, and evidence artifacts.

+
+ + + + + + + + +
Observed itemValueWhy it matters
Run sourcecento-hard-proreq-proShows a dedicated Cento route, not a generic prompt.
Runtimecento-native + GPT pro requestModel planning is inside a Cento runtime frame.
Run modebackend-plan-firstKeeps backend work structured before code execution.
ArtifactsContext, schema, request, plan, work manifest, validation, evidenceWork can be reviewed outside the chat thread.
+
+ +
+

Findings that matter

+
+

Hard-proreq is the strategic seed

It already models the desired lifecycle: typed input, Cento context, schema-backed planning, bounded backend work, and evidence.

+

Skills are not enough

Skills are useful steering instructions, but durable contracts and receipts need to live in Cento tools and manifests.

+

MCP needs a wider surface

Current MCP handles context, Taskstream, story, cluster, and bridge. It should also cover Dev Pipeline, Build, Workset, Factory, runtime, scan, and evidence checks.

+

Proof and validation need alignment

The live proof panel can show pending while hard-proreq evidence exists. The UI should resolve proof by run source.

+

The app script is doing too much

agent_work_app.py now owns API serving, docs, Dev Pipeline state, hard-proreq execution, artifacts, and handlers. That should be split before the runtime grows.

+

Evaluation needs metrics

Track cost, validation pass rate, rework rate, missing evidence, bypassed Cento routes, and time from prompt to contract.

+
+
+ The trust problem +

When the UI status, validator receipts, and evidence files disagree, the operator cannot treat the page as the control plane. Proof resolution should be normalized before more routes are added.

+
+
+ +
+

Target Cento AI loop

+

The target loop makes the model a worker inside a contract. It does not let the model invent scope and mutate the worktree in one unbounded step.

+
user request
+  -> intent route
+  -> Cento context bundle
+  -> typed contract
+  -> preflight checks
+  -> bounded model or tool lane
+  -> deterministic validation
+  -> evidence bundle
+  -> Taskstream or direct handoff
+
+
+

Runtime layers

+
    +
  1. Intent router: choose the registered Cento path.
  2. +
  3. Context builder: gather repo, tool, route, and validation context.
  4. +
  5. Contract planner: write story, workset, pipeline, and validation contracts.
  6. +
  7. Execution lanes: run bounded planner, builder, validator, docs, and integrator work.
  8. +
  9. Evidence gate: validate with commands, receipts, screenshots, and review summaries.
  10. +
+
+
+

Run envelope

+

The missing top-level artifact is cento.ai_run.v1. It should link source, route decision, context bundle, contracts, execution, artifacts, receipts, validation status, and handoff state.

+
+ Design constraint +

Strong models can plan. Code workers can edit only declared owned paths. Validators prove with deterministic evidence first.

+
+
+
+
+ +
+

How skills and MCP should change

+ + + + + + + + +
SurfaceNew roleConcrete change
SkillsThin routing adaptersTrigger the right Cento path and point to focused references. Avoid copying executable logic into skill bodies.
MCPStructured Cento interfaceAdd tools for Dev Pipeline state/start, scan, runtime, evidence check, build init, workset execute, and Factory plan/status.
CLI toolsDurable execution contractKeep cento tools and data/tools.json as the source of truth for registered operations.
ValidationMechanical done gateUse story and validation manifests, command receipts, screenshots, and evidence summaries before Review.
+
+

cento-native

Keep it short: discover Cento tools, prefer MCP, preserve dirty work, and route to Taskstream only when needed.

+

cento-ai-run

New skill: start the standard AI runtime from a prompt, screenshot, route, issue, or PR context.

+

cento-validator

New skill: read story.json and validation.json, run deterministic checks, and write validator evidence.

+
+
+ +
+

Small code structure improvement

+

Do not do a broad rewrite. Split the Dev Pipeline runtime out of the monolithic app script behind compatibility wrappers.

+
scripts/
+  agent_work_app.py
+  agent_work_api.py
+  dev_pipeline/
+    state.py
+    routes.py
+    manifests.py
+    execution.py
+    hard_proreq.py
+    validation.py
+    artifacts.py
+    schemas.py
+
    +
  • Move hard-proreq defaults to data. Store project/template/schema defaults under data/dev-pipeline/ and load them from Python.
  • +
  • Normalize statuses once. Export one backend mapping for configured, queued, running, completed, passed, failed, blocked, muted, skipped, and accepted.
  • +
  • Add proof resolver. Resolve proof by run source: workset receipt, hard-proreq evidence, build receipts, or Factory release receipts.
  • +
+
+ +
+

Roadmap

+
+

Align current Execution Flow

Fix hard-proreq proof, validation mapping, source labels, and add an MCP read tool for Dev Pipeline state.

+

Add AI run envelope

Define cento.ai_run.v1, add create/show/check commands, and link existing Dev Pipeline runs to it.

+

Widen MCP

Add structured tools for Dev Pipeline, scan, runtime, evidence, build, workset, and Factory operations.

+

Rework skills

Keep skills small and point them to MCP/CLI contracts, focused references, and evidence requirements.

+

Generalize hard-proreq

Turn the specialized route into the standard Cento AI runtime used by generic tasks, docs, UI, and Factory work.

+
+
+ +
+

Research sources

+

The full report inspected Cento core, Codex skills, GitHub plugin skills, OpenCode, docmgmt, and an Airflow pipeline project.

+ +
+
+ +
+ Cento Console docs page generated from the research report. The Markdown remains the full audit trail; this page is the operator digest. +
+
+
+ + diff --git a/docs/ai-review-unblock-autopilot.md b/docs/ai-review-unblock-autopilot.md new file mode 100644 index 0000000..b6988d1 --- /dev/null +++ b/docs/ai-review-unblock-autopilot.md @@ -0,0 +1,110 @@ +# AI Review/Unblock Autopilot + +`cento walk-autopilot review-unblock` is the evidence-gated Agent Work cleanup stage for Review, Blocked, Validating, and stale worker states. + +The stage exists because the worker pool can finish implementation work while the board still contains review-ready tasks, stale validator runs, blocked internal artifact gaps, or historical demo/test inventory. Review/Unblock turns that backlog into explicit decisions every loop instead of leaving the live worker pool with nothing launchable. + +## Operating Contract + +- Authority: report by default; bounded apply only in aggressive mode. +- Default in `walk-autopilot run`: report mode. +- Default when `--live-workers` is enabled: aggressive mode. +- Closure rule: Review items close only through `agent-work review-drain`, which requires validation pass plus evidence. +- Safety rule: if the Git dirty count changes during snapshot collection, mutating actions are blocked. +- Escalation rule: ambiguous, credential/device/LAN, missing-manifest, active-run, or failed snapshot states become `operator_needed`. + +## Commands + +```bash +cento walk-autopilot review-unblock run --mode report --json +cento walk-autopilot review-unblock run --mode aggressive --json +cento walk-autopilot review-unblock status --json +cento walk-autopilot run --review-unblock-mode report +cento walk-autopilot run --live-workers --review-unblock-mode aggressive +cento walk-autopilot run --no-review-unblock +``` + +Use report mode when validating decision quality or inspecting the current board. Use aggressive mode only when the operator wants the stage to mutate Agent Work within its caps. + +## Artifacts + +Standalone runs write to: + +```text +workspace/runs/walk-autopilot/review-unblock// +workspace/runs/walk-autopilot/review-unblock/latest/ +``` + +Loop-integrated runs write to: + +```text +workspace/runs/walk-autopilot//review-unblock/loop-0001/ +``` + +The stable artifact set is: + +- `snapshot.json` +- `snapshot/recovery-plan.json` +- `snapshot/agent-work-list.json` +- `snapshot/agent-work-runs.json` +- `snapshot/commands.json` +- `decision.json` +- `decision_report.md` +- `actions.jsonl` +- `results.json` +- `actions/-/action.json` + +Repair-task actions also materialize a draft story manifest under the action directory before calling `agent-work create`. + +## Decisions + +The stage can choose these action types: + +- `close_done`: drain a Review package through `agent-work review-drain --apply`. +- `validate_local`: run `agent-work validate-run` for a Validating issue that has canonical story and validation manifests and no active run. +- `dispatch_validator`: launch a bounded validator when local validation capacity is exhausted and the issue has valid manifests. +- `requeue_stale_dispatch`: move a stale Blocked, Running, or Validating issue back to Queued with a precise note. +- `repair_task`: create a narrow builder task for an internal artifact gap. +- `close_demo_test`: close demo/test inventory identified by Agent Work metadata when no active run exists. +- `archive_stale_historical`: reconcile or archive a stale run ledger for a Done or closed issue. +- `operator_needed`: stop for real ambiguity instead of guessing. + +## Caps + +Each run is capped so a bad rule cannot churn the whole board: + +- Close done: 20 packages. +- Local validations: 4 issues. +- Validator dispatches: 3 issues. +- Stale requeues: 6 issues. +- Repair tasks: 3 issues. +- Demo/test closures: 10 issues. +- Historical stale ledger archives: 6 runs. + +Caps should increase only after two consecutive runs show stable action types and no unexpected failures. + +## Integration Point + +Walk Autopilot runs Review/Unblock after `agent-work-hygiene` and before `agent-pool-kick --dry-run`. + +That order matters: hygiene refreshes the run/process picture first, Review/Unblock removes or repairs stale board states, and the worker pool dry-run sees a fresher queue immediately after. + +## Review/Unblock Versus Recovery Plan + +`agent-work recovery-plan` remains the lower-level board analysis tool. Review/Unblock uses it as one snapshot source, then adds: + +- canonical manifest checks, +- active-run guards, +- action caps, +- apply/report mode, +- loop metrics, +- per-action transcripts, +- latest-run status. + +Use `recovery-plan` for manual diagnosis. Use Review/Unblock when the walk autopilot needs to keep the board moving continuously. + +## Next Iteration + +The next iteration should compare consecutive `decision.json` files for action stability. Repeated `operator_needed` reasons are the best candidates for new deterministic rules, but only when the rule can be proven from structured Agent Work state, manifests, or run ledgers. + +Do not add raw prompt, stdout, or log-body persistence to this stage. If a new signal is needed, store counts, hashes, paths, and command metadata instead of raw content whenever possible. diff --git a/docs/ai-routing-nativeness-loop.md b/docs/ai-routing-nativeness-loop.md new file mode 100644 index 0000000..b64396f --- /dev/null +++ b/docs/ai-routing-nativeness-loop.md @@ -0,0 +1,132 @@ +# AI Routing Nativeness Loop + +`cento walk-autopilot routing` is the lightweight scheduled loop for improving Cento routing and Cento-native behavior without letting cron implement code. + +The loop exists because routing quality depends on several local signals that drift over time: registered command surfaces, human Docs coverage, Agent Work health, local Codex observability, skill usage, and whether the installed `cento-native` skill matches the repo copy. + +## Operating Contract + +- Cadence: every 4 hours through a marked crontab block. +- Authority: report first, then create or update one bounded Agent Work task when a change is actionable. +- Mutations from cron: reports, metrics, latest artifact mirror, and Agent Work note/task only. +- Prohibited from cron: code implementation, live ProReq expansion, live worker dispatch, destructive cleanup, or raw log capture. +- Privacy boundary: counts-only. Prompt text, raw logs, command stdout payloads, and Agent Work issue subjects are not persisted. + +## Commands + +```bash +cento walk-autopilot routing run --json +cento walk-autopilot routing status --json +cento walk-autopilot routing install-cron --every-hours 4 --json +cento walk-autopilot routing uninstall-cron --json +``` + +Use this for local validation without creating or updating Agent Work: + +```bash +cento walk-autopilot routing run --json --no-agent-work +``` + +## Artifacts + +Routing artifacts are written under: + +```text +workspace/runs/walk-autopilot/routing-native// +workspace/runs/walk-autopilot/routing-native/latest/ +``` + +The stable artifact set is: + +- `raw_counts.json` +- `decision.json` +- `decision_report.md` +- `agent_work_request.json` +- `agent-work-story.json` +- `next_iteration.md` +- `metrics.jsonl` + +`latest/` is a copied mirror of the newest run so operators and agents have a stable handoff path. + +## Signals + +The current collector records aggregate counts and status fields for: + +- Git dirty count and status-code counts. +- Routing cron marker status and schedule. +- Tool registry count and Walk Autopilot routing command coverage. +- CLI docs and human Docs coverage. +- Latest Walk Autopilot status summary. +- Nightly self-improvement status, validation status, and promotion recommendation. +- Agent Work run status, health, role, runtime, stale, running, failed, and demo/test inventory counts. +- Codex local SQLite log level counts and top targets without reading log bodies. +- Skill mention counts for known Codex skills. +- Installed versus repo `cento-native` skill file hashes. + +## Decision Rules + +The loop creates decisions from counts, not from raw text. + +High-priority decisions: + +- `repair_self_improve_before_heavy_cron`: the heavier nightly self-improvement loop is unknown, degraded, failed, incomplete, or recommends repairing the pipeline first. +- `sync_cento_native_skill`: the installed `cento-native` skill and repo copy differ or a watched file is missing. +- `register_routing_commands`: the Walk Autopilot tool registry does not expose the routing command surface. +- `dirty_worktree_changed_during_loop`: the dirty count changed during collection, so Agent Work mutation is blocked. + +Medium-priority decisions: + +- `install_routing_cron`: the marked four-hour routing cron block is missing. +- `write_human_routing_docs`: the human-facing routing loop page is missing. +- `agent_work_hygiene_cleanup`: stale or demo/test Agent Work inventory needs bounded cleanup. +- `codex_error_observability`: local Codex ERROR counts are high enough to justify a follow-up. + +Low-priority decisions can be recorded without creating Agent Work when they should trend for another iteration first. + +## Agent Work Handoff + +When actionable decisions exist, `routing run` writes `agent-work-story.json` and then creates or updates one Agent Work task in the `cento-routing-nativeness` package. + +The task owns follow-up coordination, not direct cron execution. It should repair the named issue, validate deterministically, and leave evidence in the run bundle. If a previous routing task exists and is still open, the loop updates it with a note pointing at the newest `decision_report.md`. + +Agent Work is skipped when: + +- `--no-agent-work` is passed. +- No actionable decision exists. +- The Git dirty count changes during collection. +- Agent Work create/update fails; the local `agent_work_request.json` records the failed command metadata without raw command output. + +## Cron Block + +The installed block is marked so it can be replaced safely: + +```text +# BEGIN CENTO ROUTING NATIVE LOOP +0 */4 * * * ... +# END CENTO ROUTING NATIVE LOOP +``` + +The cron command uses `flock` with: + +```text +~/.local/state/cento/routing-native-loop.lock +``` + +Cron logs append to: + +```text +workspace/logs/routing-native-loop.log +``` + +## Next Iteration + +After installation, let the loop collect at least two samples before increasing automation. The next iteration should compare action id stability, severity movement, Agent Work handoff health, and whether any collector failed to change decisions. + +Add a new collector only when all of these are true: + +- It can be represented as aggregate counts or hashes. +- It changes a concrete routing decision. +- It does not persist prompt text, raw logs, stdout payloads, secrets, or issue bodies. +- It has a deterministic test. + +Heavy ProReq automation and live worker dispatch stay outside this cron path unless an operator explicitly starts them. diff --git a/docs/ai-self-improvement-autopilot.md b/docs/ai-self-improvement-autopilot.md new file mode 100644 index 0000000..b734400 --- /dev/null +++ b/docs/ai-self-improvement-autopilot.md @@ -0,0 +1,92 @@ +# AI Self-Improvement Autopilot + +`cento parallel-delivery self-improve e2e` is the guarded end-to-end autopilot path for turning the latest self-improvement planning request into Patch Swarm candidates, Factory validation evidence, one bounded Safe Integrator apply, and an auto-merge dry-run receipt. + +It never pushes and never directly mutates `main`. + +## Command + +```bash +cento parallel-delivery self-improve e2e \ + --candidate-target 30 \ + --max-parallel-agents 3 \ + --budget-cap-usd 1 \ + --max-budget-usd 1 \ + --apply \ + --validate-each \ + --auto-merge-gate \ + --json +``` + +Use `--fixture-only` for a no-API sandbox: + +```bash +cento parallel-delivery self-improve e2e \ + --fixture-only \ + --candidate-target 10 \ + --max-parallel-agents 2 \ + --apply \ + --validate-each \ + --auto-merge-gate \ + --json +``` + +## Safety Model + +The e2e seeds from `workspace/runs/ai-self-improvement-nightly/latest/next_cycle_request.json`. If that file is absent, non-fixture mode runs the existing four-pass `self-improve run` planning loop first. Fixture mode uses the deterministic seed fallback and skips planning dispatch. + +Patch Swarm candidates are retargeted to a run-scoped sandbox so fixture apply evidence does not depend on dirty or untracked operator files. When `--apply` is set, Factory applies at most `--limit` selected candidate, default `1`, in the Safe Integrator worktree. + +`--auto-merge-gate` runs: + +```bash +cento factory merge FACTORY_RUN --auto-merge-main --dry-run --json +``` + +The e2e does not pass `--push`. A blocked dry-run receipt caused by dirty main or wrong branch is valid environment evidence, not a merge attempt. + +## Spend Caps + +Default non-fixture mode includes one metered `api-openai` sandbox candidate through the `api-patch-proposal` profile in `.cento/api_workers.yaml`. + +The API sandbox blocks before dispatch when: + +- `OPENAI_API_KEY` is missing, +- estimated spend exceeds `--budget-cap-usd`, +- `--budget-cap-usd` exceeds `--max-budget-usd`, +- the hard cap exceeds the rollout ceiling. + +Patch Swarm writes `usage_guard.json`, `provider_usage.jsonl`, and `candidate_spend_ledger.jsonl`. The e2e also writes `spend_summary.json`. + +## Artifacts + +Each run writes under: + +```text +workspace/runs/ai-self-improvement-e2e// +workspace/runs/ai-self-improvement-e2e/latest/ +``` + +Stable artifacts: + +- `e2e_manifest.json` +- `self_improve_source.json` +- `patch_swarm_result.json` +- `factory_promotion.json` +- `safe_integrator_apply.json` +- `auto_merge_gate.json` +- `spend_summary.json` +- `validation_summary.json` +- `handoff.md` + +The Patch Swarm and Factory run directories are linked from the e2e manifest. + +## Statuses + +`ready_for_apply` means selected candidates were promoted to Factory, `validate-fanout` passed, and no apply was requested. + +`applied` means one Safe Integrator worktree apply succeeded and release candidate evidence was written. + +`auto_merge_blocked_by_environment` means Safe Integrator evidence passed, then the auto-merge dry-run blocked on environment gates such as dirty main or not being on `main`. No merge or push happened. + +`blocked` means a required source, budget, Patch Swarm, Factory, apply, or validation gate failed before a non-destructive success state. diff --git a/docs/ai-self-improvement-log.md b/docs/ai-self-improvement-log.md new file mode 100644 index 0000000..0682780 --- /dev/null +++ b/docs/ai-self-improvement-log.md @@ -0,0 +1,1930 @@ +# AI Self-Improvement Append-Only Log + +This is the durable human-facing Docs log for major Cento self-improvement work. It is meant to keep future agents from going in circles by making prior decisions, evidence, gaps, and next steps easy to check before changing Cento routing, autonomy, pipelines, skills, validation, observability, or operator workflow. + +The scheduled planning loop in `docs/ai-self-improvement-nightly.md` can produce future recommendations. This log records the actual major self-improvement steps that were planned, implemented, validated, or deliberately deferred. + +## Append-Only Rules + +- Append every new record at the bottom of this file. +- Do not rewrite, reorder, or delete previous records. +- If a prior record needs correction, append a new correction record with `corrects_record_id`. +- If sensitive material was accidentally written, redact the minimum required text and append a redaction record explaining what category was removed. +- Before starting a major self-improvement step, read the latest relevant records and state what is different about the current step. + +## Record Schema + +Use this Markdown shape for each record: + +```markdown +### YYYY-MM-DDTHH:MM:SSZ - Short Title + +- `record_id`: kebab-case-date-title +- `actor`: codex | human | automated-loop | mixed +- `scope`: skills | docs | pipeline | routing | observability | validation | agent-work | storage | other +- `status`: planned | implemented | validated | deferred | failed | superseded +- `artifacts_changed`: paths or run directories +- `evidence`: commands, run ids, receipts, tests, logs, screenshots, or links +- `checked_prior_records`: record ids consulted before the change +- `corrects_record_id`: optional record id if this is a correction + +#### Trigger + +The operator request, incident, scheduled loop, or observation that caused the step. + +#### What Changed + +Concrete behavior, code, docs, routing, skill, or process changes. + +#### What Worked + +Evidence-backed observations about successful decisions, tools, or implementation paths. + +#### What Did Not Work + +Friction, failed assumptions, confusing loops, missing evidence, or validation gaps. + +#### Next Steps + +Specific follow-up work that should happen after this record. + +#### Suggestions + +Optional product, workflow, or automation ideas that are not yet committed work. + +#### Tags + +Comma-separated tags such as `cento-native`, `self-improvement`, `routing`, `factory`, `workset`. +``` + +## Records + +### 2026-05-05T21:30:40Z - Self-Improvement Log Started After Parallel Train Promotion E2E + +- `record_id`: 2026-05-05-self-improvement-log-started-parallel-train-promotion-e2e +- `actor`: codex +- `scope`: skills, docs, pipeline, routing, validation +- `status`: implemented +- `artifacts_changed`: `.codex/skills/cento-native/SKILL.md`, `skills/codex/cento-native/SKILL.md`, `docs/ai-self-improvement-log.md`, `docs/nav.html` +- `evidence`: `train-e2e-promotion-20260505T2135Z`, `workspace/runs/parallel-delivery/train/train-e2e-promotion-20260505T2135Z`, `workspace/runs/factory/parallel-train-train-e2e-promotion-20260505T2135Z`, `python3 -m pytest tests/test_parallel_integration_train.py -q`, `python3 -m pytest tests/test_parallel_integration_train.py tests/test_dev_pipeline_delivery.py tests/test_self_improvement_loop.py -q`, `make check` +- `checked_prior_records`: none, this is the first record +- `corrects_record_id`: none + +#### Trigger + +The operator asked Cento Native to maintain and check against an append-only self-improvement log, document every major self-improvement step, create the Markdown schema, and start the log with a first record. + +The immediate context was a completed parallel-integration-train self-improvement step. The operator had correctly noticed that the process felt circular: Workset execution already existed, but the missing end-to-end piece was promotion from a completed parallel Workset train into the Factory Safe Integrator path. + +#### What Changed + +- Added a `Self-Improvement Log` rule to the active installed `cento-native` skill. +- Added the same rule to the repo copy of the `cento-native` skill without overwriting existing differences between the two copies. +- Created this human-facing Docs log with append-only rules, a reusable Markdown schema, and the first record. +- Linked the log from `docs/nav.html` so it is discoverable alongside the other human Docs. +- Documented the already-completed parallel train bridge as the first self-improvement record because it is the motivating example for avoiding repeated loops. + +The parallel train work completed immediately before this log added: + +- `cento parallel-delivery train run --workset-execute` for real Workset execution through the train. +- `cento parallel-delivery train promote` for handing accepted Workset patch bundles to Factory/Safe Integrator. +- `cento parallel-delivery train e2e` for one-command plan, execute, validate, and promote flow. +- Docs, tests, tool registry, completion, and generated tool index updates for the new command surface. + +#### What Worked + +- The existing Workset executor was already a useful parallel work substrate. The right fix was to bridge it into promotion rather than invent another worker system. +- Factory/Safe Integrator remained the integration authority. Promotion reuses that path instead of creating a second apply gate. +- The real fixture e2e run produced a `ready_for_apply` promotion decision and a valid Factory handoff in dry-run mode. +- Focused tests plus `make check` passed after the parallel train work. +- The new log gives future agents a stable place to check what was already done before planning another self-improvement pass. + +#### What Did Not Work + +- Before this record, the distinction between "Workset execution exists" and "promotion bridge is missing" was only implicit in the conversation and code state. +- That missing durable memory made the work feel like it was circling back to the same plan instead of naming the exact remaining gap. +- Promotion initially treated task-level Workset failures too strictly. The implementation had to be adjusted so task-level failures can be handed to Factory for classification while accepted patch bundles still promote. +- The repo already had many unrelated dirty files. The skill and Docs updates had to stay narrowly scoped and avoid normalizing unrelated generated or in-progress changes. + +#### Next Steps + +- Future major self-improvement work should start by reading the latest relevant records in this file. +- Every major step should append a new record before final reporting. +- Add a lightweight validator later that can check whether a self-improvement change touched routing, skills, autonomy, or pipeline code without a corresponding log record. +- Consider teaching the nightly self-improvement loop to include the latest records in its planning context and to propose the next log entry as part of its evidence handoff. +- Periodically review whether the active installed skill and repo skill copy have intentional differences, then decide whether to add a sync/check command instead of relying on manual awareness. + +#### Suggestions + +- Keep this log human-readable and short enough to scan, but evidence-rich enough for agents to make decisions from it. +- Prefer concrete run ids, receipt paths, and command outputs over broad summaries. +- When a user says a pipeline feels circular, add a record that separates "already done", "missing bridge", "validated now", and "next unresolved gap". +- Add tags consistently so future tools can index this Markdown without requiring a new database. + +#### Tags + +`cento-native`, `self-improvement`, `append-only-log`, `parallel-delivery`, `workset`, `factory`, `safe-integrator`, `docs`, `routing`, `validation` + +### 2026-05-05T22:08:25Z - Tool Foundry MVP Implemented + +- `record_id`: 2026-05-05-tool-foundry-mvp-implemented +- `actor`: codex +- `scope`: pipeline, routing, validation, docs, storage, agent-work +- `status`: implemented +- `artifacts_changed`: `scripts/tool_foundry.py`, `tests/test_tool_foundry.py`, `data/tools.json`, `scripts/completion/_cento`, `Makefile`, `docs/tool-foundry.md`, `docs/nav.html`, `docs/tool-index.md`, `docs/platform-support.md`, `.codex/skills/cento-native/SKILL.md`, `skills/codex/cento-native/SKILL.md`, `docs/ai-self-improvement-log.md` +- `evidence`: `./scripts/cento.sh foundry e2e --fixture client-intake-hub --dry-run --run-id foundry-cli-e2e-20260505 --max-parallel 6 --json`, `python3 -m pytest tests/test_tool_foundry.py -q`, `python3 -m pytest tests/test_tool_foundry.py tests/test_parallel_integration_train.py tests/test_object_storage.py -q`, `make check`, `./scripts/cento.sh docs foundry`, `./scripts/cento.sh tools` +- `checked_prior_records`: `2026-05-05-self-improvement-log-started-parallel-train-promotion-e2e` +- `corrects_record_id`: none + +#### Trigger + +The operator accepted the Tool Foundry plan and asked to implement it. The strategic goal was to make Cento more scalable, cheaper, and ready to create tools for a career consulting business, starting with a reusable pipeline rather than one bespoke CRM feature. + +#### What Changed + +- Added the registered `cento foundry` tool. +- Implemented `create`, `plan`, `execute`, `promote`, `status`, `validate`, and `e2e` commands. +- Made the first fixture tool `client-intake-hub` for career consulting. +- Routed Foundry through existing Factory planning, Workset manifests, parallel-delivery train e2e, train-to-Factory promotion, storage policy, cost receipts, and demo evidence. +- Added live `api-openai` budget gates requiring both `--budget-usd` and `--max-budget-usd`. +- Added a v1 hard-cap guard rejecting live caps above `$20`. +- Added human Docs, registry entries, zsh completion, platform/tool indexes, and Cento Native skill routing hints. + +#### What Worked + +- The existing parallel train promotion bridge was enough to make Foundry e2e real instead of simulated. +- Dry-run Foundry e2e now produces a passing run with zero AI cost and concrete receipts under `workspace/runs/foundry//`. +- `cento docs foundry` and `cento tools` now expose the command through normal Cento discovery. +- The budget guard rejected uncapped live execution before creating a run. +- The adjacent test slice passed across Foundry, parallel train promotion, and Object Storage. + +#### What Did Not Work + +- The first Workset design used run-scoped generated files as fixture worker write paths. That passed Workset shape validation but failed inside isolated worker worktrees because those generated files are not tracked in git. +- The fix was to keep generated product artifacts run-scoped while using existing tracked docs/standards files as fixture worker write targets for the dry-run proof path. +- Factory validation returns `blocked` before live patch collection, which is expected for a planning handoff. Foundry now records that as acceptable planning evidence and relies on Workset/train execution plus Foundry validation for the final e2e gate. + +#### Next Steps + +- Extend Foundry from fixture-only Client Intake Hub to an actual CRM-backed generated tool surface. +- Add a Foundry dashboard view showing runs, receipts, costs, validation, and generated tool previews. +- Add optional OCI artifact upload for non-sensitive generated evidence after explicit operator approval. +- Add a self-improvement validator that checks major pipeline/routing changes include a new log record. +- Teach Foundry to generate Worksets that target newly created files through API/artifact materialization instead of only tracked fixture targets. + +#### Suggestions + +- Keep `cento foundry e2e --dry-run` as the release gate for every future Foundry improvement. +- Treat live Foundry execution as an acceleration lane, not the baseline path. +- Use Client Intake Hub as the business-facing seed, then add Deliverable Generator and Foundry Dashboard as the next two fixtures. +- Add a small cost dashboard before raising any live cap beyond `$20`. + +#### Tags + +`cento-native`, `self-improvement`, `tool-foundry`, `career-consulting`, `client-intake-hub`, `factory`, `workset`, `parallel-delivery`, `cost-guard`, `storage-policy`, `docs`, `validation` + +### 2026-05-05T23:15:31Z - Tool Foundry Real-File Materialization V2 + +- `record_id`: 2026-05-05-tool-foundry-real-file-materialization-v2 +- `actor`: codex +- `scope`: pipeline, routing, validation, docs, crm, ui, self-improvement +- `status`: implemented +- `artifacts_changed`: `scripts/tool_foundry.py`, `tests/test_tool_foundry.py`, `scripts/crm_module.py`, `templates/crm/app.js`, `templates/crm/styles.css`, `templates/foundry/client-intake-hub/*`, `docs/client-intake-hub.md`, `docs/tool-foundry.md`, `data/tools.json`, `scripts/completion/_cento`, `docs/tool-index.md`, `docs/platform-support.md`, `docs/nav.html`, `docs/ai-self-improvement-log.md` +- `evidence`: `./scripts/cento.sh foundry e2e --fixture client-intake-hub --dry-run --real-files --target-root templates/foundry/client-intake-hub --run-id foundry-real-files-e2e-json-20260505 --max-parallel 6 --json`, `./scripts/cento.sh foundry materialize foundry-real-files-e2e-json-20260505 --target-root templates/foundry/client-intake-hub --apply --json`, `./scripts/cento.sh foundry validate foundry-real-files-e2e-json-20260505 --json`, `python3 -m pytest tests/test_tool_foundry.py -q`, `python3 -m pytest tests/test_tool_foundry.py tests/test_parallel_integration_train.py tests/test_object_storage.py -q`, `make check`, `curl http://127.0.0.1:47865/api/foundry/tools`, `workspace/tmp/crm-foundry-studio.png`, `workspace/tmp/client-intake-hub-preview.png` +- `checked_prior_records`: `2026-05-05-self-improvement-log-started-parallel-train-promotion-e2e`, `2026-05-05-tool-foundry-mvp-implemented` +- `corrects_record_id`: none + +#### Trigger + +The operator accepted the plan to move Foundry beyond demo fixture edits and asked to implement real-file materialization for the Client Intake Hub. + +The prior Tool Foundry MVP proved Factory, Workset, train promotion, cost receipts, storage policy, and validation, but it still used existing docs/standards fixture files as worker write targets because isolated worktrees could not see run-scoped generated files. + +#### What Changed + +- Added `cento foundry materialize RUN_ID` with `--dry-run`, `--apply`, and `--target-root`. +- Added `cento foundry e2e --real-files` so real-file planning runs after the fixture train validates. +- Added real-file artifacts: `real_file_manifest.json`, `materialization_plan.json`, and `materialization_receipt.json`. +- Materialized the first repo-ready Client Intake Hub bundle under `templates/foundry/client-intake-hub/`. +- Added `docs/client-intake-hub.md` as the human-facing Docs entry for the materialized MVP. +- Wired `cento crm serve` to expose Foundry metadata at `/api/foundry/tools` and serve the preview under `/foundry/client-intake-hub/client-intake-hub.html`. +- Added a CRM Studio card for materialized Foundry tools. +- Updated command registry, completion, generated tool index/platform docs, and docs nav. + +#### What Worked + +- The dry-run e2e stayed deterministic and zero-cost while producing a real-file materialization plan. +- Applying materialization wrote only the approved target root and the approved human Docs page. +- Re-running materialization after apply is idempotent: all files resolve to `skip_identical`. +- The CRM API reports the Client Intake Hub as `materialized`. +- Browser screenshots confirmed both the CRM Studio card and the standalone preview render visibly. +- Focused tests, adjacent pipeline/storage tests, and `make check` passed. + +#### What Did Not Work + +- The first test pass exposed that copied run-scoped command and validation artifacts still contained run-specific ids and paths. +- That would have made every later run look like an overwrite conflict. +- The fix was to keep run-scoped evidence under `workspace/runs/foundry/...` while making the repo-ready command map and validation plan stable. +- A nested materialize call initially printed a human status line before outer e2e JSON. The command now supports quiet nested execution. + +#### Next Steps + +- Add a Foundry dashboard that lists runs, costs, materialization receipts, validation state, and preview links. +- Add a safe CRM action that can launch a new Client Intake Hub run from the Studio card. +- Run a tiny live `api-openai` rehearsal with a `$1-$2` cap after the deterministic path stays stable. +- Add optional OCI upload for non-sensitive generated evidence only after explicit operator approval. +- Add a validator that flags major pipeline/routing changes without a new self-improvement log record. + +#### Suggestions + +- Keep real-file materialization as the bridge between generated run artifacts and durable product files. +- Avoid putting run ids into repo-ready templates unless the file is explicitly a receipt. +- Treat CRM discovery as metadata-first until the generated tools need deeper state integration. +- Make the next business tool use this same dry-run, materialize, preview, validate contract before adding live model spend. + +#### Tags + +`cento-native`, `self-improvement`, `tool-foundry`, `real-files`, `materialization`, `client-intake-hub`, `crm`, `docs`, `workset`, `factory`, `validation`, `ui` + +### 2026-05-06T00:15:41Z - Patch Swarm MVP Implemented + +- `record_id`: 2026-05-06-patch-swarm-mvp-implemented +- `actor`: codex +- `scope`: pipeline, routing, validation, docs, ui, observability, cost, autopilot +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery.py`, `.cento/runtimes.yaml`, `scripts/agent_work_app.py`, `scripts/walk_autopilot.py`, `tests/test_patch_swarm.py`, `data/tools.json`, `scripts/completion/_cento`, `docs/patch-swarm.md`, `docs/parallel-ai-delivery-roadmap.md`, `docs/agent-work-runtimes.md`, `docs/tool-index.md`, `docs/platform-support.md`, `docs/nav.html`, `docs/ai-self-improvement-log.md` +- `evidence`: `./scripts/cento.sh parallel-delivery patch-swarm e2e --run-id patch-swarm-e2e-20260505 --candidate-target 100 --max-parallel-agents 5 --providers codex-exec,claude-code,api-openai --fixture --json`, `./scripts/cento.sh parallel-delivery patch-swarm validate patch-swarm-e2e-20260505 --json`, `./scripts/cento.sh walk-autopilot patch-swarm run --run-id patch-swarm-autopilot-20260505 --candidate-target 100 --max-parallel-agents 5 --json`, `./scripts/cento.sh walk-autopilot patch-swarm status --json`, `./scripts/cento.sh runtime check claude-code-fast --json`, `python3 -m py_compile scripts/parallel_delivery.py scripts/agent_work_app.py scripts/walk_autopilot.py`, `python3 -m pytest tests/test_patch_swarm.py -q`, `python3 -m pytest tests/test_patch_swarm.py tests/test_parallel_integration_train.py tests/test_dev_pipeline_delivery.py tests/test_walk_autopilot.py -q`, `python3 -m json.tool data/tools.json`, `./scripts/cento.sh docs parallel-delivery`, `make check` +- `checked_prior_records`: `2026-05-05-self-improvement-log-started-parallel-train-promotion-e2e`, `2026-05-05-tool-foundry-mvp-implemented`, `2026-05-05-tool-foundry-real-file-materialization-v2` +- `corrects_record_id`: none + +#### Trigger + +The operator accepted the Patch Swarm plan and asked to implement it end to end. The requested direction was aggressive AI cost effectiveness and massively parallel AI development: five or more agents, one hundred or more patch proposals, provider compatibility for `codex exec`, Claude Code, and OpenAI API patch proposals, integration with the existing Cento parallel execution UI, ten ProReq pipeline executions, and one dedicated integration execution. + +#### What Changed + +- Added `cento parallel-delivery patch-swarm` with `plan`, `execute`, `integrate`, `validate`, `status`, and `e2e`. +- Added ten ProReq execution lanes: request decomposition, Codex Exec adapter, Claude Code adapter, OpenAI patch proposal adapter, candidate normalization, dedupe clustering, deterministic validator fanout, cost/latency ledger, Dev Pipeline Studio UI, and autopilot coordinator hooks. +- Added one dedicated serialized integrator execution that selects one validated candidate per ProReq lane and writes a Safe Integrator handoff instead of mutating the main worktree. +- Added provider-normalized `candidate_patch.v1` receipts and fixture patch files for `codex-exec`, `claude-code`, and `api-openai`. +- Added Patch Swarm run artifacts: manifest, ProReq execution manifest, candidate index, dedupe clusters, ranking, cost ledger, patch swarm receipt, integration receipts, validation summary, UI state, decision report, and Safe Integrator handoff. +- Added the `claude-code-fast` runtime profile in `.cento/runtimes.yaml` for future Claude Code command execution. +- Added a Patch Swarm Dev Pipeline Studio blueprint and execution bridge so the existing pipeline UI can seed, run, finish, and display Patch Swarm state. +- Added Walk Autopilot hooks: optional loop stage flags plus `cento walk-autopilot patch-swarm run/status` for one-shot autopilot-compatible coordination. +- Updated command registry, zsh completion, human Docs, tool index, platform support docs, and runtime docs. + +#### What Worked + +- The deterministic Patch Swarm e2e produced `100` candidate patches, `10` ProReq executions, `10` selected winners, all three requested providers, one dedicated integrator, passing validation, and an estimated fixture cost ledger of `$0.412500`. +- The main proof run lives at `workspace/runs/parallel-delivery/patch-swarm/patch-swarm-e2e-20260505/`. +- The autopilot wrapper produced a separate passing run at `workspace/runs/parallel-delivery/patch-swarm/patch-swarm-autopilot-20260505/` and summary artifacts under `workspace/runs/walk-autopilot/patch-swarm/patch-swarm-autopilot-20260505/`. +- `ui_state.json` mirrors to `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/patch-swarm/latest_ui_state.json`, giving the existing UI one stable source for candidate counts, provider mix, costs, validation, winners, and integration state. +- The Claude Code runtime profile validated with the installed `claude` executable. +- Focused Patch Swarm tests, adjacent parallel train/Dev Pipeline/Walk Autopilot tests, JSON registry validation, docs lookup, and `make check` passed. + +#### What Did Not Work + +- Live provider dispatch is intentionally not enabled by default. The MVP normalizes provider contracts and proves the high-parallel artifact flow through fixture-safe candidate generation. +- The Safe Integrator handoff is artifact-only. It does not apply selected patches to the repo yet. +- The Dev Pipeline Studio integration exposes Patch Swarm state through backend run payloads and mirrored UI state, but richer frontend views such as a candidate matrix, provider comparison table, and dedupe cluster explorer remain future work. +- Cost numbers are deterministic estimates for provider comparison and budget gating rehearsals, not invoices from live Codex/Claude/OpenAI execution. + +#### Next Steps + +- Add explicit live candidate dispatch gates for `codex exec`, Claude Code, and OpenAI API providers with per-provider budget caps, duplicate saturation stopping, and hard fail-closed cost ceilings. +- Teach the dedicated integrator to materialize selected candidates into Safe Integrator patch bundles and dry-run apply plans. +- Add candidate applyability checks: patch parse, target ownership, protected path rejection, dependency lockfile policy, and test impact hints. +- Add a Dev Pipeline Studio candidate matrix showing provider, lane, score, estimated cost, duplicate cluster, validation status, and selected winner. +- Run a tiny live trial with a strict dollar cap after the fixture path stays stable across repeated autopilot loops. + +#### Suggestions + +- Track cost per accepted patch, duplicate saturation by provider, validator pass rate, and integrator rejection reason as first-class metrics before scaling beyond `100` proposals. +- Use Patch Swarm as the high-volume proposal engine and keep Factory/Safe Integrator as the only mutation path. +- Prefer many cheap proposal candidates followed by deterministic pruning over expensive long-context builders for every lane. +- Add provider A/B reporting before deciding whether Codex Exec, Claude Code, or OpenAI API should own each lane by default. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `proreq`, `workset`, `dev-pipeline-studio`, `walk-autopilot`, `cost-effectiveness`, `codex-exec`, `claude-code`, `openai-api`, `safe-integrator`, `docs`, `validation` + +### 2026-05-06T00:27:29Z - Factory Scale Final Test Implemented + +- `record_id`: 2026-05-06-factory-scale-final-test-implemented +- `actor`: codex +- `scope`: factory, walk-autopilot, proreq-light, patch-swarm, cron, validation, docs, self-improvement +- `status`: implemented +- `artifacts_changed`: `scripts/walk_autopilot.py`, `scripts/dev_pipeline_hard_proreq.py`, `tests/test_walk_autopilot.py`, `docs/factory-1000-patch-swarm-roadmap.md`, `data/tools.json`, `scripts/completion/_cento`, `docs/nav.html`, `docs/tool-index.md`, `docs/platform-support.md`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m py_compile scripts/walk_autopilot.py scripts/dev_pipeline_hard_proreq.py scripts/proreq_light.py scripts/parallel_delivery.py`, `python3 -m pytest tests/test_walk_autopilot.py -q`, `python3 -m pytest tests/test_walk_autopilot.py tests/test_patch_swarm.py tests/test_dev_pipeline_delivery.py -q`, `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `./scripts/cento.sh docs walk-autopilot`, `./scripts/cento.sh walk-autopilot factory-scale start --run-id factory-scale-validation-20260506T002606Z --duration-hours 0.1 --proreq-executions 2 --crontab-file /tmp/cento-factory-scale-crontab-factory-scale-validation-20260506T002606Z.txt --json`, `./scripts/cento.sh walk-autopilot factory-scale tick --run-id factory-scale-validation-20260506T002606Z --json`, `./scripts/cento.sh walk-autopilot factory-scale status --run-id factory-scale-validation-20260506T002606Z --json`, `./scripts/cento.sh walk-autopilot factory-scale start --run-id factory-scale-patch-validation-20260506T002642Z --duration-hours 0.1 --proreq-executions 3 --min-proreq-calls 30 --patch-swarm --crontab-file /tmp/cento-factory-scale-crontab-factory-scale-patch-validation-20260506T002642Z.txt --json`, `./scripts/cento.sh walk-autopilot factory-scale tick --run-id factory-scale-patch-validation-20260506T002642Z --json` x3, `./scripts/cento.sh parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json`, `make check` +- `checked_prior_records`: `2026-05-06-patch-swarm-mvp-implemented`, `2026-05-05-tool-foundry-real-file-materialization-v2`, `2026-05-05-tool-foundry-mvp-implemented` +- `corrects_record_id`: none + +#### Trigger + +The operator provided the six-hour Factory scale final test plan and asked for implementation in a fresh context. The goal was to connect Walk Autopilot, ProReq-light, Patch Swarm, cron scheduling, append-only ledgers, and Factory/Safe Integrator safety boundaries into one repeatable final test. + +#### What Changed + +- Added `cento walk-autopilot factory-scale` with `start`, `tick`, `status`, `install-cron`, and `uninstall-cron`. +- Added the managed cron block marker `# BEGIN CENTO FACTORY SCALE FINAL TEST` with a 12-minute schedule, `flock`, and a run-deadline check. +- Added log-derived run artifacts under `workspace/runs/walk-autopilot/factory-scale-/`: roadmap, config, execution manifest, events, thoughts, ProReq-light call ledger, metrics, spend ledger, handoff, cron docs, isolated ProReq-light roots, and Patch Swarm milestone folders. +- Added a 30-execution manifest that derives ten Patch Swarm milestone groups and expects 300 ProReq-light command-call records plus 1,000 Patch Swarm candidate receipts. +- Added one-execution-per-tick selection. Each execution appends the ten required ProReq-light command calls: intake, context, screenshot, pro-request, codex-plan, backend-work, integration-plan, validation-plan, deliver `--no-full-check --json`, and evidence. +- Added a ProReq-light pipeline-root environment override through `CENTO_DEV_PIPELINE_STUDIO_ROOT` so batch execution can avoid mutating Dev Pipeline Studio's active `execution_run.json`. +- Added roadmap docs, registry entries, completion, tool index/platform docs, and a Docs nav link. + +#### What Worked + +- Focused tests cover cron install/uninstall idempotence, one pending ProReq-light execution per tick, append-only call-ledger behavior, the 100-call minimum after ten simulated executions, 30-to-10 Patch Swarm grouping, status derived from call logs, and isolated ProReq-light roots. +- The short start/tick/status validation run used a temporary crontab and produced one completed ProReq-light execution with ten call records. +- The 3-tick factory-scale validation run completed one Patch Swarm milestone: 3 ProReq-light executions, 30 calls, 1 Patch Swarm run, and 100 candidate receipts. +- Patch Swarm fixture e2e still produced 100 candidates, 10 selected winners, passing validation, and a Safe Integrator handoff. +- `make check` passed after the command and docs updates. + +#### What Did Not Work + +- The full six-hour, 30-tick cron run was not executed during implementation. Validation used temporary crontab files and short runs to avoid mutating the real crontab or waiting six hours. +- Default factory-scale ProReq-light mode is ledger-only and API-safe. Running the actual ProReq-light commands is available behind `--execute-proreq`, but that heavier local Codex path was not exercised in this implementation pass. +- Patch Swarm remains fixture/candidate-receipt first. Live Codex/Claude/OpenAI provider dispatch and Safe Integrator apply are still later milestones behind explicit budget and validation gates. + +#### Next Steps + +- Run the full six-hour factory-scale schedule when the operator wants the final soak, then inspect `handoff.md`, `metrics.jsonl`, and milestone handoffs. +- Add optional status mirroring for factory-scale runs into Dev Pipeline Studio UI state. +- Add a tiny `--execute-proreq` rehearsal with one execution after confirming local Codex runtime availability and acceptable wall-clock time. +- Teach Factory/Safe Integrator to consume selected Patch Swarm candidate receipts as dry-run apply plans. + +#### Suggestions + +- Keep factory-scale status derived from JSONL ledgers; do not add mutable counters. +- Use temporary crontab files for tests and real crontab only for operator-started soak runs. +- Promote live provider fanout only after cost/latency admission and duplicate saturation metrics are visible. +- Treat the 1,000-candidate target as a receipt-generation and pruning proof until Safe Integrator apply plans are deterministic. + +#### Tags + +`cento-native`, `self-improvement`, `factory-scale`, `walk-autopilot`, `proreq-light`, `patch-swarm`, `cron`, `append-only`, `safe-integrator`, `cost-effectiveness`, `validation`, `docs` + +### 2026-05-06T05:30:00Z - Factory Scale No-Overlap Advance Implemented + +- `record_id`: 2026-05-06-factory-scale-no-overlap-advance +- `actor`: codex +- `scope`: walk-autopilot, factory-scale, patch-swarm, safe-integrator, spend-guard, docs, validation +- `status`: implemented +- `artifacts_changed`: `scripts/walk_autopilot.py`, `tests/test_walk_autopilot.py`, `data/tools.json`, `scripts/completion/_cento`, `docs/tool-index.md`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m py_compile scripts/walk_autopilot.py`, `python3 -m pytest tests/test_walk_autopilot.py -q`, `python3 -m pytest tests/test_walk_autopilot.py tests/test_patch_swarm.py tests/test_dev_pipeline_delivery.py -q`, `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `./scripts/cento.sh docs walk-autopilot`, `./scripts/cento.sh walk-autopilot factory-scale preflight --run-id factory-scale-sleep-20260506T051332Z --json`, `./scripts/cento.sh walk-autopilot factory-scale advance --run-id factory-scale-sleep-20260506T051332Z --promotion-limit 25 --json`, `./scripts/cento.sh parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json`, `make check` +- `checked_prior_records`: `2026-05-06-factory-scale-final-test-implemented`, `2026-05-06-patch-swarm-mvp-implemented` +- `corrects_record_id`: none + +#### Trigger + +The operator confirmed that budget was available but asked not to overlap the existing autopilot and not to waste spend through accidental tight ChatGPT Pro/API loops. The completed `factory-scale-sleep-20260506T051332Z` run needed to become actionable Factory input rather than another manifest. + +#### What Changed + +- Added `cento walk-autopilot factory-scale preflight` to detect existing factory-scale cron, run status, and active factory-scale, ProReq-light, or Patch Swarm processes before starting or advancing. +- Added `cento walk-autopilot factory-scale advance` to reuse a completed factory-scale run, index Patch Swarm candidate receipts, write a candidate matrix, generate Safe Integrator promotion plans, and produce a morning report. +- Added a live OpenAI/API guard for the advance lane: dashboard-total hard-cap gating, hourly call limits, minimum spacing, global lock path, and fail-closed behavior when live API is not requested or usage cannot be trusted. +- Changed latest factory-scale run selection from lexical ordering to mtime ordering so older validation runs do not mask the real newest run. +- Stopped completed factory-scale ticks from appending repeated `run_complete` metrics/events after the completion event already exists. + +#### What Worked + +- Preflight against `factory-scale-sleep-20260506T051332Z` returned `reuse_completed_run`, no cron marker, and no active overlap. +- Advance wrote `workspace/runs/walk-autopilot/factory-scale-sleep-20260506T051332Z/advance/` with `candidate-matrix.json`, `safe-integrator-promotion-plan.json`, `live-api-guard.json`, `no-overlap-preflight.json`, and `morning-report.md`. +- The candidate matrix indexed `1,000` validated candidate receipts, `100` selected candidates, provider counts `codex-exec=340`, `claude-code=330`, `api-openai=330`, and no validation errors. +- The promotion plan selected `25` dry-run Safe Integrator candidates and kept `apply=false`. +- Live OpenAI/API stayed disabled because it was not requested; the guard recorded fail-closed status rather than guessing usage. + +#### What Did Not Work + +- No direct OpenAI usage API polling was added in this pass. The implemented guard relies on the existing dashboard-total snapshot route plus local append-only spend/rate ledgers. +- The promotion plan is still dry-run and artifact-only. It does not apply candidate patches or mutate the main worktree. +- The candidate matrix is a derived JSON artifact, not yet a full Dev Pipeline Studio interactive view. + +#### Next Steps + +- Teach Factory/Safe Integrator to consume `safe-integrator-promotion-plan.json` and create isolated apply/validation worktrees for the top candidates. +- Add a Dev Pipeline Studio candidate matrix view backed by the new `candidate-matrix.json`. +- Add optional official OpenAI usage polling only after verifying the current official API surface and keeping fail-closed behavior. +- Run one tiny live API review only after a dashboard-total baseline is supplied and the hard cap/rate limiter are active. + +#### Suggestions + +- Keep completed-run advance as the default path after large candidate-generation runs; do not rerun factory-scale unless the old run is stale or incomplete. +- Prefer Codex/Claude/local execution for broad implementation work and reserve live OpenAI API for compact, high-leverage structured review. +- Keep promotion limits small until deterministic applyability validation is reliable. + +#### Tags + +`cento-native`, `self-improvement`, `factory-scale`, `walk-autopilot`, `patch-swarm`, `safe-integrator`, `no-overlap`, `spend-guard`, `cost-effectiveness`, `validation` + +### 2026-05-06T14:11:00Z - Factory Scale Day Autopilot Started + +#### Trigger + +The operator asked for a full day of more aggressive autopilot after the overnight Factory scale final test completed. The target was to scale from 300 logged ProReq-light calls to 1,000+ today, with a primary goal around 3,000 calls and a hard ceiling of 10,000 calls, while avoiding overlap and accidental metered OpenAI/API loops. + +#### What Changed + +- Added `cento walk-autopilot factory-scale start-day` for day-scale runs that derive execution count from a target ProReq-light command-call count. +- Added `--batch-size` support to `factory-scale tick`, so cron can advance multiple ProReq-light executions per guarded tick. +- Extended factory-scale config, cron, status, and handoff artifacts with run mode, batch size, target calls, max calls, remaining calls, configurable schedule, and configurable lock name. +- Kept day mode API-safe by default: ProReq-light remains ledger/local execution, Patch Swarm remains fixture candidate-receipt generation, and live OpenAI/API remains disabled unless explicit budget gates are passed. +- Updated tests, tool registry examples, docs output, and shell completions for day-scale operation. + +#### What Worked + +- Validation passed: + - `python3 -m py_compile scripts/walk_autopilot.py` + - `python3 -m pytest tests/test_walk_autopilot.py -q` + - `python3 -m pytest tests/test_walk_autopilot.py tests/test_patch_swarm.py tests/test_dev_pipeline_delivery.py -q` + - `python3 -m json.tool data/tools.json` + - `./scripts/cento.sh docs walk-autopilot` + - `./scripts/cento.sh parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json` + - `make check` +- No-overlap preflight returned `safe_to_start`; the prior overnight run was completed and no factory-scale cron or active process was present. +- Started `factory-scale-day-20260506` with: + - `300` ProReq-light executions. + - `3,000` expected ProReq-light command-call records. + - `10,000` max allowed ProReq-light command-call records. + - `100` Patch Swarm fixture milestones. + - `10,000` expected candidate patch receipts. + - `5` executions per tick on a `*/10 * * * *` cron cadence. +- Seeded the first batch immediately: `5` executions complete, `50` ProReq-light call records, `1` Patch Swarm milestone, and `100` candidate patch receipts. + +#### What Did Not Work + +- Day-scale still generates fixture candidate receipts and handoffs first; it does not yet apply candidate patches through Safe Integrator. +- Direct OpenAI usage API polling remains deferred. The current guard keeps metered API share at `0` and fails closed unless live API is explicitly enabled with budget evidence. + +#### Next Steps + +- Let the installed day-scale cron continue advancing `factory-scale-day-20260506` without overlapping ticks. +- Consume the generated Safe Integrator handoffs from the best milestones in isolated worktrees once applyability validation is ready. +- Add usage polling only behind a fail-closed budget gate and only after checking the official OpenAI usage API surface. + +#### Tags + +`cento-native`, `self-improvement`, `factory-scale`, `walk-autopilot`, `day-scale`, `patch-swarm`, `no-overlap`, `spend-guard`, `cost-effectiveness`, `validation` + +### 2026-05-06T14:31:00Z - Factory Scale Day Lane Accelerated With Spark And Claude + +#### Trigger + +The operator asked to continue scaling and explicitly asked to use Spark and Claude Code in addition to the active Factory scale lane. + +#### What Changed + +- Tuned `factory-scale-day-20260506` from `5` executions every `10` minutes to `10` executions every `5` minutes, preserving the same `3,000` ProReq-light command-call target and `10,000` hard ceiling. +- Reinstalled the managed Factory scale cron block with the same `factory-scale-day.lock` flock guard. +- Created and dispatched two bounded Taskstream side lanes: + - `#1000232` on Codex Spark (`gpt-5.3-codex-spark`) to build a candidate selector. + - `#1000231` on Claude Code (`claude-sonnet-4-6`) to build an integration risk audit. +- Kept side-lane ownership disjoint: + - Spark writes only `workspace/runs/walk-autopilot/factory-scale-day-20260506/scaleout/spark-selector/`. + - Claude writes only `workspace/runs/walk-autopilot/factory-scale-day-20260506/scaleout/claude-audit/`. + +#### What Worked + +- The accelerated cron tick advanced the run to: + - `30/300` ProReq-light executions. + - `300/3000` logged ProReq-light command calls. + - `10/100` Patch Swarm fixture milestones. + - `1000/10000` candidate patch receipts. +- Spark generated: + - `workspace/runs/walk-autopilot/factory-scale-day-20260506/scaleout/spark-selector/selection.json` + - `workspace/runs/walk-autopilot/factory-scale-day-20260506/scaleout/spark-selector/selection.md` + - Validated with `agent-work validate-run 1000232`, result `pass`. +- Claude generated: + - `workspace/runs/walk-autopilot/factory-scale-day-20260506/scaleout/claude-audit/audit.json` + - `workspace/runs/walk-autopilot/factory-scale-day-20260506/scaleout/claude-audit/audit.md` + - Validated with `agent-work validate-run 1000231`, result `pass`. +- Claude identified two hard blockers before live Claude/API fanout: + - Budget gates have only run in ledger-only mode. + - Patch Swarm candidates remain fixture-generated; no real Claude receipt has been schema-validated yet. + +#### What Did Not Work + +- Spark initially wrote its handoff to the draft `{run_dir}` path. The handoff was copied into the canonical issue directory and validation passed there. +- The run still does not apply candidate patches. Safe Integrator worktree apply remains the next gated step. +- Live API remains intentionally disabled; no direct OpenAI usage polling was added in this step. + +#### Next Steps + +- Let the accelerated cron continue toward the `3,000` call / `10,000` receipt target. +- Use Spark selection output to seed Safe Integrator worktree batches after duplicate and touched-path checks. +- Use Claude's blockers as gating criteria before enabling any real Claude/provider fanout. +- Add a small live-provider sandbox only after the budget gate can enforce a hard cap and record usage evidence. + +#### Tags + +`cento-native`, `self-improvement`, `factory-scale`, `walk-autopilot`, `day-scale`, `spark`, `claude-code`, `patch-swarm`, `safe-integrator`, `spend-guard`, `validation` + +### 2026-05-06T18:06:35Z - Parallel Code Delivery Rollout Gates Implemented + +- `record_id`: 2026-05-06-parallel-code-delivery-rollout-gates-implemented +- `actor`: codex +- `scope`: pipeline, validation, routing, factory, patch-swarm +- `status`: implemented +- `artifacts_changed`: `scripts/factory.py`, `scripts/factory_integrator_core.py`, `scripts/parallel_delivery.py`, `tests/test_patch_swarm.py`, `tests/test_factory_parallel_rollout.py`, `docs/factory.md`, `docs/patch-swarm.md`, `data/tools.json`, `scripts/completion/_cento`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m pytest tests/test_patch_swarm.py tests/test_factory_parallel_rollout.py -q`, `python3 -m py_compile scripts/factory.py scripts/factory_integrator_core.py scripts/parallel_delivery.py` +- `checked_prior_records`: `2026-05-05-self-improvement-log-started-parallel-train-promotion-e2e`, `2026-05-06-factory-scale-day-autopilot-started`, `2026-05-06-factory-scale-day-lane-accelerated-with-spark-and-claude` +- `corrects_record_id`: none + +#### Trigger + +The operator accepted the four-day rollout plan and asked to implement the path from fixture Patch Swarm runs toward real parallel code delivery, with no single-threaded retesting and with auto-merge/push allowed only behind hard gates. + +#### What Changed + +- Added Factory `validate-fanout` for parallel, cacheable candidate validation keyed by base SHA, patch hash, and validation suite. +- Made Factory Safe Integrator apply logs append-only instead of deleting a prior `apply-log.jsonl`. +- Added Factory `merge --auto-merge-main` with optional `--push`, local clean-worktree checks, release/rollback/fanout gates, pre/post validation, and merge/push receipts. +- Added Patch Swarm live budget guard artifacts: `usage_guard.json`, `provider_usage.jsonl`, and `candidate_spend_ledger.jsonl`. +- Made Patch Swarm live execution fail closed unless the plan is live-enabled, a budget cap is supplied, estimated spend is within cap, and `CENTO_PATCH_SWARM_LIVE_ADAPTERS=1` is set. +- Added Patch Swarm promotion from selected `candidate_patch.v1` receipts into Factory patch bundles, Factory apply plans, and Factory validation fanout when `integrate --apply` or `--factory-run` is used. + +#### What Worked + +- Existing fixture Patch Swarm behavior stayed compatible while gaining schema checks and optional Factory promotion. +- Factory fanout validation caches repeated candidate checks and avoids rerunning the same deterministic gate work. +- The auto-merge command is present but blocks in unsafe conditions such as missing integration worktree, dirty main worktree, wrong branch, missing rollback plan, or failed validation. + +#### What Did Not Work + +- Live provider commands are still not launched by default. The implementation intentionally blocks until provider adapter configuration is explicitly enabled. +- Fixture Patch Swarm diffs are useful for receipt scale tests but are not guaranteed to apply cleanly as real patches; Factory fanout now exposes that before apply. +- Full `make check` was not run before this record; focused tests and py_compile were run first. + +#### Next Steps + +- Add provider-command adapter configuration for Codex Spark and Claude Code candidate generation. +- Run a tiny live-provider sandbox with a very low cap and verify real candidate receipts before raising parallelism. +- Use `validate-fanout` output to select only applyable candidates for Safe Integrator batches. +- After a clean low-risk release branch passes, exercise `factory merge --auto-merge-main --push` in a clean main worktree. + +#### Suggestions + +- Keep fixture scale as the load-test lane and live provider scale as a capped ramp, not a sudden jump. +- Prefer Codex Spark and Claude Code subscription lanes for broad candidate generation; reserve metered API calls for small structured review or schema-normalization gaps. + +#### Tags + +`cento-native`, `self-improvement`, `parallel-delivery`, `patch-swarm`, `factory`, `safe-integrator`, `validate-fanout`, `auto-merge`, `spend-guard`, `validation` + +### 2026-05-06T18:34:02Z - Factory Scale Promotion Bridge And Applyable Fixture Ramp + +- `record_id`: 2026-05-06-factory-scale-promotion-bridge-applyable-ramp +- `actor`: codex +- `scope`: pipeline, validation, factory-scale, patch-swarm, safe-integrator +- `status`: implemented +- `artifacts_changed`: `scripts/walk_autopilot.py`, `scripts/parallel_delivery.py`, `tests/test_walk_autopilot.py`, `tests/test_patch_swarm.py`, `data/tools.json`, `scripts/completion/_cento`, `docs/tool-index.md`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m py_compile scripts/walk_autopilot.py scripts/parallel_delivery.py`, `python3 -m pytest tests/test_walk_autopilot.py tests/test_patch_swarm.py tests/test_factory_parallel_rollout.py -q`, `./scripts/cento.sh parallel-delivery patch-swarm e2e --run-id patch-swarm-applyable-validation-20260506 --candidate-target 100 --max-parallel-agents 5 --fixture --factory-run workspace/runs/factory/patch-swarm-applyable-validation-20260506 --json`, `./scripts/cento.sh walk-autopilot factory-scale start-day --run-id factory-scale-aggressive-applyable-20260506 --target-proreq-calls 1000 --max-proreq-calls 10000 --duration-hours 12 --batch-size 100 --patch-swarm-candidate-target 100 --patch-swarm-max-parallel-agents 5 --no-install-cron --json`, `./scripts/cento.sh walk-autopilot factory-scale tick --run-id factory-scale-aggressive-applyable-20260506 --batch-size 100 --json`, `./scripts/cento.sh walk-autopilot factory-scale advance --run-id factory-scale-aggressive-applyable-20260506 --promotion-limit 330 --json`, `./scripts/cento.sh walk-autopilot factory-scale promote --run-id factory-scale-aggressive-applyable-20260506 --limit 330 --factory-run workspace/runs/factory/factory-scale-aggressive-applyable-20260506-promotion-exclusive-330 --json` +- `checked_prior_records`: `2026-05-06-factory-scale-no-overlap-advance`, `2026-05-06-factory-scale-day-lane-accelerated-with-spark-and-claude`, `2026-05-06-parallel-code-delivery-rollout-gates-implemented` +- `corrects_record_id`: `2026-05-06-parallel-code-delivery-rollout-gates-implemented` + +#### Trigger + +The operator asked for a more aggressive autopilot push while preserving no-overlap and spend controls. + +#### What Changed + +- Added `cento walk-autopilot factory-scale promote`, which consumes `advance/safe-integrator-promotion-plan.json`, normalizes entries into `candidate_patch.v1`, and promotes them into Factory patch bundles, apply plans, and parallel validation fanout. +- Made factory-scale promotion exclusive-path by default so repeated milestone candidates do not reach Factory as overlapping owned scopes. +- Fixed Patch Swarm fixture generation to emit syntactically applyable unified diffs and to mark candidates validated only when `git apply --check` passes. +- Fixed factory-scale `advance` for partial final milestones, where a manifest milestone can exist without a Patch Swarm summary. + +#### What Worked + +- The completed 10,000-receipt day run was promoted far enough to expose the old fixture-patch flaw: Factory rejected overlapping paths first, then fanout caught corrupt patch syntax. +- A fresh 100-candidate Patch Swarm run promoted into Factory with `fanout_status=passed`. +- A new no-cron, no-live-API factory-scale run completed 100 ProReq-light executions, 1,000 command-call records, 33 Patch Swarm milestones, and 3,300 candidate receipts in one guarded local batch. +- The new run advanced 3,300 receipts, selected 330 candidates, promoted 10 exclusive-path winners, and produced `ready_for_apply` Factory evidence with 6 fanout-passed candidates and 4 docs/registry-gate rejections. + +#### What Did Not Work + +- The older 10,000-receipt fixture run remains useful for scale and selection evidence, but its historical candidate diffs are not safe apply inputs. +- Four promoted command-surface candidates remain blocked by the existing docs/registry gate because their patches do not include the required registry/docs companion updates. +- No live Codex/Claude/API candidate generation was launched in this step; spend stayed local/fixture. + +#### Next Steps + +- Add a small provider-command sandbox for Codex Spark and Claude Code candidate receipts behind a hard budget/use gate. +- Teach Factory/Patch Swarm selection to prefer docs/registry-complete candidates for command-surface paths. +- Run Safe Integrator `--apply` only on fanout-passed, semantically useful candidates, not fixture comments. + +#### Tags + +`cento-native`, `self-improvement`, `factory-scale`, `walk-autopilot`, `patch-swarm`, `safe-integrator`, `validate-fanout`, `spend-guard`, `no-overlap`, `validation` + +### 2026-05-06T18:43:00Z - Industrial OS Hero Pane Routed Through Live Cento State + +- `record_id`: 2026-05-06-industrial-os-hero-mission-router +- `actor`: codex +- `scope`: industrial-os, terminal-ui, taskstream, agent-runs, cluster, operator-workflow +- `status`: implemented +- `artifacts_changed`: `scripts/industrial_mission.py`, `scripts/industrial_panel.py`, `scripts/industrial_panel_e2e.sh`, `scripts/industrial_mission_contract_check.py`, `scripts/fixtures/industrial_panel/mission-busy.json`, `scripts/fixtures/industrial_panel/mission-clean.json`, `scripts/fixtures/industrial_panel/mission-degraded-data-source.json`, `scripts/fixtures/industrial_panel/mission-action-model.json`, `scripts/fixtures/industrial_panel/mission-sources/busy.json`, `scripts/fixtures/industrial_panel/mission-sources/clean.json`, `scripts/fixtures/industrial_panel/mission-sources/degraded-data-source.json`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m py_compile scripts/industrial_mission.py scripts/industrial_panel.py scripts/industrial_mission_contract_check.py`, `python3 -m json.tool scripts/fixtures/industrial_panel/mission-busy.json`, `python3 scripts/industrial_mission_contract_check.py`, `./scripts/industrial_panel_e2e.sh`, live render captured with `python3 scripts/industrial_panel.py hero --once --plain` +- `checked_prior_records`: `2026-05-06-parallel-code-delivery-rollout-gates-implemented`, `2026-05-06-factory-scale-applyable-promotion-implemented` +- `corrects_record_id`: none + +#### Trigger + +The operator asked to replace the fake Industrial OS hero pane with a Cento-native mission router derived from real Taskstream, agent-run, cluster, git, jobs, and quick-action state. + +#### What Changed + +- Added `scripts/industrial_mission.py`, a read-only adapter that builds a render-ready mission model from `agent-work list --json`, `agent-work runs --json --active`, cluster snapshots, `git status --short`, jobs state, and registered industrial actions. +- Replaced the hardcoded hero queue, fake action count, mission brief, static context engine, and fake hub keys with the live mission model. +- Added deterministic mission fixture support through `CENTO_INDUSTRIAL_MISSION_FIXTURE`. +- Added hero handlers for dry-run, selected context, status-note drafting, refresh, help, and safe command execution. +- Added per-action JSON receipts under `workspace/runs/industrial-os/action-runs/` for hero actions, including dry-run status, selected item, source, command, cwd, exit code, output tail, and timestamp. +- Kept the hero command surface conservative: review-ready work uses `review-drain --dry-run`, queued work uses `dispatch --dry-run`, cluster work prefers diagnostic commands, and shell wrappers are blocked. + +#### What Worked + +- The busy fixture orders Review-ready, Review-gated, Blocked, Queued dry-run, manual/untracked shell, cluster, and git items in the requested priority. +- The compact and 120-column hero renders fit within terminal width without the old fake strings or unimplemented capture/block actions. +- The contract check verifies selected context, dry-run execution, unsafe shell blocking, and receipt creation. +- A live unfixtured render now shows real Taskstream counts, manual Codex/Claude shells, cluster degradation, and dirty worktree state. + +#### What Did Not Work + +- The live render still depends on synchronous `agent-work` commands, so a slow Taskstream backend can delay hero refresh. +- The hero currently displays the first nine mission items; deeper queues remain summarized rather than scroll-windowed across all live items. + +#### Next Steps + +- Add a short-lived mission-model cache if synchronous Taskstream calls make the live pane feel sluggish. +- Add a scroll window for large Review queues so selection can move past the first nine items without losing the keyboard contract. +- Consider showing receipt paths in a small history strip after repeated hero actions. + +#### Tags + +`cento-native`, `self-improvement`, `industrial-os`, `taskstream`, `agent-work`, `cluster`, `terminal-ui`, `safe-actions`, `validation` + +### 2026-05-06T18:53:30Z - Self-Improvement Autopilot E2E Wired Through Patch Swarm And Factory + +- `record_id`: 2026-05-06-self-improvement-autopilot-e2e-patch-swarm-factory +- `actor`: codex +- `scope`: self-improvement, patch-swarm, api-sandbox, factory, safe-integrator, auto-merge, docs +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery.py`, `.cento/api_workers.yaml`, `tests/test_patch_swarm.py`, `tests/test_self_improvement_loop.py`, `tests/test_factory_parallel_rollout.py`, `docs/ai-self-improvement-autopilot.md`, `docs/ai-self-improvement-nightly.md`, `docs/patch-swarm.md`, `docs/factory.md`, `docs/nav.html`, `data/tools.json`, `docs/tool-index.md`, `scripts/completion/_cento`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m py_compile scripts/parallel_delivery.py scripts/cento_openai_worker.py scripts/factory.py scripts/factory_integrator_core.py`, `python3 -m pytest tests/test_patch_swarm.py tests/test_self_improvement_loop.py tests/test_factory_parallel_rollout.py -q`, `./scripts/cento.sh parallel-delivery self-improve e2e --run-id self-improve-e2e-fixture-final --fixture-only --candidate-target 10 --max-parallel-agents 2 --apply --validate-each --auto-merge-gate --json`, `python3 -m json.tool data/tools.json`, `python3 scripts/tool_index.py --registry data/tools.json --output docs/tool-index.md`, `./scripts/cento.sh docs parallel-delivery`, `./scripts/cento.sh docs factory`, `make check` +- `checked_prior_records`: `2026-05-06-parallel-code-delivery-rollout-gates-implemented`, `2026-05-06-factory-scale-promotion-bridge-applyable-ramp` +- `corrects_record_id`: none + +#### Trigger + +The operator asked to implement the accepted self-improvement autopilot e2e plan in a fresh context. + +#### What Changed + +- Added `cento parallel-delivery self-improve e2e` as a durable orchestration command. +- The command consumes latest `self-improve` `next_cycle_request.json`; fixture mode uses the deterministic seed fallback, while non-fixture mode can run the existing planning loop when latest is absent. +- Added the `api-patch-proposal` OpenAI worker profile and Patch Swarm conversion from completed `patch_proposal.v1` API worker artifacts into `candidate_patch.v1` receipts. +- Reworked Patch Swarm live API gating to block on `OPENAI_API_KEY`, `--budget-cap-usd`, `--max-budget-usd`, and estimated metered sandbox spend before dispatch. +- Allowed small Patch Swarm candidate targets for sandbox e2e runs. +- The self-improvement e2e retargets candidates to a run-scoped sandbox, promotes winners into Factory, runs `validate-fanout`, applies at most one candidate through the Safe Integrator worktree when requested, and runs `factory merge --auto-merge-main --dry-run --json` without `--push`. +- Added e2e artifacts and latest mirror under `workspace/runs/ai-self-improvement-e2e/`. + +#### What Worked + +- Fixture e2e reached `auto_merge_blocked_by_environment` with one Safe Integrator-applied sandbox candidate, passing Factory fanout and writing a dry-run auto-merge receipt with `push_requested=false`. +- Missing API key and over-cap live API sandbox paths block before API worker dispatch. +- The API worker artifact conversion test produces a valid `candidate_patch.v1` receipt and apply-checkable unified diff. + +#### What Did Not Work + +- The first fixture smoke showed that dirty/untracked source-path fixture patches could validate in the operator worktree but fail inside a clean integration worktree. The e2e now retargets to a sandbox path before candidate generation. +- Factory per-patch validation commands were initially relative to the integration worktree cwd; Patch Swarm promotion now writes absolute validation artifact paths. +- Live API e2e was not run because this validation path intentionally avoided metered calls. + +#### Next Steps + +- Run the optional live e2e with `CENTO_RUN_LIVE_API_E2E=1` and `OPENAI_API_KEY` after confirming the operator wants metered spend. +- Add Codex Spark and Claude Code real provider-command adapters behind separate hard gates. +- Keep auto-merge as dry-run evidence until the main worktree is clean and the operator explicitly asks for a push-enabled release gate. + +#### Tags + +`cento-native`, `self-improvement`, `parallel-delivery`, `patch-swarm`, `api-openai`, `safe-integrator`, `factory`, `auto-merge`, `spend-guard`, `validation` + +### 2026-05-06T19:05:00Z - Agent Processes Pane Shows Doing Signal + +- `record_id`: 2026-05-06-agent-processes-doing-signal +- `actor`: codex +- `scope`: industrial-os, agent-processes, agent-work, terminal-ui, operator-workflow +- `status`: implemented +- `artifacts_changed`: `scripts/agent_work.py`, `scripts/industrial_aux_tui.go`, `scripts/agent_processes_tui.go`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m py_compile scripts/agent_work.py`, `go build -o /tmp/cento-industrial-aux-check ./scripts/industrial_aux_tui.go`, `go build -o /tmp/cento-agent-processes-check ./scripts/agent_processes_tui.go`, `./scripts/industrial_aux_tui.sh agents --once`, `./scripts/agent_processes_tui.sh --once`, width check for both rendered outputs +- `checked_prior_records`: `2026-05-06-industrial-os-hero-mission-router`, `2026-05-06-self-improvement-autopilot-e2e-patch-swarm-factory` +- `corrects_record_id`: none + +#### Trigger + +The operator showed the Industrial OS Agent Processes pane and asked to add what the live agents are doing, with the result still fitting in the fixed pane. + +#### What Changed + +- Added process cwd capture for untracked interactive Codex/Claude shells in `agent-work runs --json --active`. +- Enriched Agent Processes run rows with issue subjects from the live Taskstream list when a run has an issue id but the run ledger lacks a subject. +- Changed the Industrial OS aux Agent Processes pane to render a compact first line plus a clipped `doing:` line for each live process. +- Updated the standalone `cento agent-processes --once` dashboard to rename the active-runs subject column to `DOING` and use issue subject, package, or command/cwd fallback. + +#### What Worked + +- The aux pane now shows manual shells as `doing: claude @ ~` or `doing: codex @ ~` instead of only `manual -> shell`. +- Managed runs can show their Taskstream issue subject inline when present. +- The aux render stayed within 58 columns and 20 lines; the standalone dashboard stayed within 100 columns. + +#### What Did Not Work + +- Manual shells still cannot expose a real task unless they are attached to a Taskstream issue or ledger. The pane now shows the honest command/cwd signal rather than inventing a task. + +#### Next Steps + +- Encourage launching agent work through the ledger wrapper or Taskstream dispatch so the pane can show exact issue subjects instead of only command/cwd for manual sessions. +- Consider adding a voluntary session note field for manual shells if operators want richer labels without forcing dispatch. + +#### Tags + +`cento-native`, `self-improvement`, `industrial-os`, `agent-processes`, `agent-work`, `terminal-ui`, `manual-shells`, `validation` + +### 2026-05-11T18:11:37Z - Patch Swarm Product Module MVP + +- `record_id`: 2026-05-11-patch-swarm-product-module-mvp +- `actor`: codex +- `scope`: patch-swarm, cento-web-app, parallel-delivery, safe-integrator, local-repos +- `status`: implemented +- `artifacts_changed`: `scripts/agent_work_app.py`, `scripts/parallel_delivery.py`, `templates/agent-work-app/index.html`, `templates/agent-work-app/app.js`, `templates/agent-work-app/styles.css`, `tests/test_patch_swarm.py`, `docs/patch-swarm.md`, `docs/cento-web-app.md`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m py_compile scripts/parallel_delivery.py scripts/agent_work_app.py`, `node --check templates/agent-work-app/app.js`, `python3 -m pytest tests/test_patch_swarm.py -q`, `curl -fsS http://127.0.0.1:47911/api/patch-swarm/repos`, `curl -fsS http://127.0.0.1:47911/api/patch-swarm/runs`, `playwright screenshot --viewport-size=1440,1100 --wait-for-selector='#patchSwarmView:not(.hidden)' --wait-for-timeout=1000 http://127.0.0.1:47911/patch-swarm workspace/tmp/patch-swarm-desktop.png`, `playwright screenshot --full-page --viewport-size=390,900 --wait-for-selector='#patchSwarmView:not(.hidden)' --wait-for-timeout=1000 http://127.0.0.1:47911/patch-swarm workspace/tmp/patch-swarm-mobile-full.png`, `workspace/tmp/patch-swarm-candidate-review.png` +- `checked_prior_records`: `2026-05-06-patch-swarm-mvp-implemented`, `2026-05-06-self-improvement-autopilot-e2e-patch-swarm-factory` +- `corrects_record_id`: none + +#### Trigger + +The operator provided the Patch Swarm product roadmap and asked for implementation in a fresh context. + +#### What Changed + +- Added standalone Cento Console routes for `/patch-swarm` and `/patch-swarm/runs/:run_id`. +- Added Patch Swarm product APIs for repo discovery, run creation, run detail, approval, rejection, and supervised apply. +- Repo discovery now surfaces branch, head, dirty paths, protected dirty paths, and likely test commands for local Git repositories. +- Product run creation stores selected repo, task brief, provider preset, validation profile, and UI metadata while retargeting generated candidate patch receipts to run-scoped paths in the selected repo. +- Approval and rejection write product receipts before apply; apply is blocked until approval exists. +- Non-Cento repos apply through a dedicated product worktree receipt; Cento repo applies can still hand off through Factory/Safe Integrator. +- Added a product-grade Patch Swarm UI with repo picker, task composer, run history, candidate ranking, diff preview, approval gate, rejection, and worktree apply actions. +- Updated Patch Swarm docs and the Cento web app section list to make Patch Swarm a first-class module. + +#### What Worked + +- The new API discovered local Git repos and reported protected dirty worktree state. +- Focused Patch Swarm tests covered local repo selection, run lifecycle metadata, approval/rejection, supervised external worktree apply, protected dirty path blocking, and app-shell routing. +- Browser validation confirmed the product route, mobile stacking, run-detail route, ranked candidate list, and diff preview render without runtime errors. + +#### What Did Not Work + +- An older Cento Console process was already serving port `47910`, so visual validation used a fresh instance on `47911`. +- Local Codex and Claude live candidate adapters are still future work; the product route records provider/runtime metadata and uses the existing Patch Swarm engine defaults unless hard-gated live API mode is explicitly requested. + +#### Next Steps + +- Add hard-gated local Codex/Claude provider command adapters that produce real `candidate_patch.v1` receipts. +- Add first-class Playwright regression tests for the new product UI flow instead of relying only on scripted screenshot validation. +- Dogfood one apply-disabled live run on Cento, then one supervised Safe Integrator apply on a separate clean local repo. + +#### Tags + +`cento-native`, `patch-swarm`, `cento-console`, `parallel-delivery`, `factory`, `safe-integrator`, `local-first`, `ui`, `validation` + +### 2026-05-11T19:09:58Z - Patch Swarm First-Run Clarity UI + +- `record_id`: 2026-05-11-patch-swarm-first-run-clarity-ui +- `actor`: codex +- `scope`: patch-swarm, cento-web-app, operator-workflow, ui +- `status`: implemented +- `artifacts_changed`: `templates/agent-work-app/app.js`, `templates/agent-work-app/index.html`, `templates/agent-work-app/styles.css`, `docs/ai-self-improvement-log.md` +- `evidence`: `node --check templates/agent-work-app/app.js`, `python3 -m pytest tests/test_patch_swarm.py -q`, `curl -fsS http://127.0.0.1:47912/api/patch-swarm/repos`, `curl -fsS http://127.0.0.1:47912/api/patch-swarm/runs`, `npx playwright screenshot --viewport-size=1365,1000 --wait-for-selector='#patchSwarmView:not(.hidden)' --wait-for-timeout=1000 http://127.0.0.1:47912/patch-swarm workspace/tmp/patch-swarm-after-1365.png`, `npx playwright screenshot --viewport-size=390,900 --wait-for-selector='#patchSwarmView:not(.hidden)' --wait-for-timeout=1000 http://127.0.0.1:47912/patch-swarm workspace/tmp/patch-swarm-after-mobile.png`, `npx playwright screenshot --viewport-size=2048,1000 --wait-for-selector='#patchSwarmView:not(.hidden)' --wait-for-timeout=1000 http://127.0.0.1:47912/patch-swarm workspace/tmp/patch-swarm-after-2048.png`, Playwright DOM checks for default startable repo selection, blocked Cento gating, empty-task gating, disabled review actions, mobile no-overflow, and no console/page errors +- `checked_prior_records`: `2026-05-11-patch-swarm-product-module-mvp` +- `corrects_record_id`: none + +#### Trigger + +The operator provided a 180-minute implementation plan to improve Patch Swarm first-run clarity without changing backend run semantics. + +#### What Changed + +- Sorted repository options so startable repos appear first, labeled blocked repos in-place, and defaulted to the first `can_start=true` repo instead of selecting blocked Cento. +- Added explicit composer states for Ready, Blocked, Task required, Starting, Run created, and Failed. +- Kept Fixture mode presented as the safe/no-spend default and added reassurance that generation does not mutate the selected repo. +- Reworked empty run detail state so "No run selected" shows the next action and hides zero-value stats. +- Made legacy engine-only runs visually distinct from product runs and added repo, status, candidate, approval, and apply facts to history rows. +- Gated Approve, Apply, and Reject actions from selected/validated candidate, supervised approval, and selected-candidate state. +- Tightened responsive Patch Swarm and Software Delivery Hub rail layout so the mobile viewport reaches the Patch Swarm composer without horizontal overflow. + +#### What Worked + +- Existing repo and run APIs already exposed the state needed for first-run clarity, so this remained a UI-only pass. +- The blocked Cento checkout stayed visible with its protected dirty path, while the default repo changed to a startable repository. +- Browser checks confirmed empty-task and blocked-repo gates, disabled review actions before a run is selected, no page errors, and no mobile horizontal overflow. + +#### What Did Not Work + +- The live local run list currently contains only legacy engine-only runs, so visual validation confirmed the legacy styling path; product-row styling is covered by the same renderer but was not backed by an existing local product run. +- The shared console topbar is still tall on narrow mobile viewports; the Patch Swarm rail now compresses enough to expose the composer, but broader topbar redesign was out of scope. + +#### Next Steps + +- Add a lightweight Playwright regression around `/patch-swarm` first-run controls once the repo has a browser-test harness. +- Dogfood one fixture product run in a clean non-Cento repo, then capture a product-history screenshot and candidate-review state. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `cento-console`, `operator-workflow`, `ui`, `validation` + +### 2026-05-11T20:06:49Z - Patch Swarm Product Release Candidate Gate + +- `record_id`: 2026-05-11-patch-swarm-product-rc-gate +- `actor`: codex +- `scope`: patch-swarm, cento-console, local-fixture-product-workflow, api-contract, ui-validation +- `status`: implemented +- `artifacts_changed`: `scripts/agent_work_app.py`, `scripts/patch_swarm_product_e2e.py`, `templates/agent-work-app/app.js`, `templates/agent-work-app/index.html`, `templates/agent-work-app/styles.css`, `tests/test_patch_swarm.py`, `docs/patch-swarm.md`, `docs/ai-self-improvement-log.md` +- `evidence`: `node --check templates/agent-work-app/app.js`, `python3 -m py_compile scripts/agent_work_app.py scripts/parallel_delivery.py scripts/patch_swarm_product_e2e.py`, `python3 -m pytest tests/test_patch_swarm.py -q`, `python3 scripts/patch_swarm_product_e2e.py`, `make check`, `workspace/runs/patch-swarm-product-e2e/patch-swarm-product-e2e-20260511T200649Z/summary.json`, screenshots under `workspace/runs/patch-swarm-product-e2e/patch-swarm-product-e2e-20260511T200649Z/screenshots/` +- `checked_prior_records`: `2026-05-11-patch-swarm-product-module-mvp`, `2026-05-11-patch-swarm-first-run-clarity-ui` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Production Step 2 and asked to implement the safe local fixture product release-candidate gate before any live provider rollout. + +#### What Changed + +- Added `run_kind` to Patch Swarm run list/detail payloads and `action_gates` to run detail payloads. +- Enforced product approval, rejection, and apply gates in the backend; engine-only runs now remain read-only through the product API. +- Kept product runs fixture-only for this release candidate and deferred live provider dispatch. +- Added clean, unprotected-dirty, and protected-dirty repo states with explicit safety labels. +- Added product create and no-selected-repo-mutation receipts for fixture creation and worktree apply. +- Routed product apply through Patch Swarm-owned product worktrees only, even when a request includes `use_factory`. +- Updated the Patch Swarm UI to render review action disabled states from `action_gates` and show decision report, candidate index, selected repo, worktree, and no-mutation receipt evidence. +- Added `scripts/patch_swarm_product_e2e.py` to run the local product lifecycle and capture required screenshots at `390x900`, `1365x1000`, and `2048x1000`. + +#### What Worked + +- The product e2e proved repo discovery, protected dirty blocking, unprotected dirty labeling, fixture run creation, rejection, approval, approval-gated apply, worktree-only apply, no selected-repo mutation, nonblank screenshots, no horizontal overflow, and no browser console errors. +- Screenshot inspection caught a hidden-state CSS specificity defect; the e2e was tightened to assert correct empty/detail panel visibility and passed after the fix. +- `make check` passed after the focused Patch Swarm and product e2e gates. + +#### What Did Not Work + +- The first screenshot pass showed the empty detail panel alongside loaded run details because component display rules overrode the generic `.hidden` rule. The fix added a Patch Swarm-scoped hidden rule and e2e assertions for that state. + +#### Next Steps + +- Keep live Codex/Claude/OpenAI provider dispatch deferred until a separate gated rollout. +- Promote the product e2e command into any release checklist that validates Patch Swarm UI/API behavior. +- Consider adding a small browser-test harness around `action_gates` once Cento has a first-class frontend regression runner. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `product-rc`, `local-fixture`, `api-contract`, `worktree-only`, `no-mutation`, `ui`, `validation` + +### 2026-05-12T17:50:25Z - Patch Swarm Product Architecture Contract + +- `record_id`: 2026-05-12-patch-swarm-product-architecture-contract +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, product-architecture, factory, workset, build, taskstream-visibility, safe-integrator +- `status`: documented +- `artifacts_changed`: `docs/patch-swarm.md`, `docs/patch-swarm-lifecycle.md`, `docs/patch-swarm-implementation-map.md`, `docs/patch-swarm-validation-matrix.md`, `docs/ai-self-improvement-log.md`, `workspace/runs/patch-swarm-call-1-product-architecture/` +- `evidence`: `workspace/runs/patch-swarm-call-1-product-architecture/discovery.log`, `workspace/runs/patch-swarm-call-1-product-architecture/validation.log`, `workspace/runs/patch-swarm-call-1-product-architecture/spec-summary.md` +- `checked_prior_records`: `2026-05-11-patch-swarm-product-module-mvp`, `2026-05-11-patch-swarm-first-run-clarity-ui`, `2026-05-11-patch-swarm-product-rc-gate` +- `corrects_record_id`: none + +#### Trigger + +The operator requested Call 1 for the Patch Swarm / Parallel Software Delivery system: a durable product architecture spec and implementation map, not a runtime implementation. + +#### What Changed + +- Promoted `docs/patch-swarm.md` into the canonical product spec for Patch Swarm / Parallel Software Delivery. +- Added the planned `cento parallel-delivery init|plan|emit-prompts|collect|validate|integrate|rc|status|evidence|demo` contract while preserving the existing implemented `cento parallel-delivery patch-swarm ...` command family. +- Defined the artifact lifecycle, run states, task states, path leasing rules, patch bundle contract, deterministic validation contract, Safe Integrator contract, release candidate contract, Console/Taskstream visibility, and unsafe rejection rules. +- Added supporting lifecycle, implementation-map, and validation-matrix docs. +- Wrote discovery and validation evidence under `workspace/runs/patch-swarm-call-1-product-architecture/`. + +#### What Worked + +- Discovery found existing `parallel-delivery`, Factory, Workset, Build, Patch Swarm, Console, and Safe Integrator surfaces, so the spec could route through existing Cento architecture instead of inventing a duplicate workflow. +- The docs-only validation passed with required headings, states, CLI contract, artifact paths, unsafe rules, implementation milestones, validation matrix rows, and `cento docs` smoke commands. + +#### What Did Not Work + +- `cento docs parallel-delivery` still reflects registry metadata and the canonical `docs/patch-swarm.md`; the new companion docs are linked from the canonical spec but were not added to `data/tools.json` in this docs-only call. + +#### Next Steps + +- Implement the run directory and artifact schema slice. +- Implement request intake / ProReq packet generation. +- Implement Factory task splitting, Workset leasing, prompt emission, patch collection, deterministic validation, Safe Integrator queueing, release candidate building, Console/Taskstream summaries, and the bounded e2e demo harness in separate slices. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `factory`, `workset`, `build`, `safe-integrator`, `taskstream`, `validation`, `docs` + +### 2026-05-12T19:10:55Z - Patch Swarm Repo Recon Baseline + +- `record_id`: 2026-05-12-patch-swarm-repo-recon-baseline +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, repo-recon, path-ownership, registered-entrypoints, dirty-work-preservation +- `status`: documented +- `artifacts_changed`: `docs/ai-self-improvement-log.md`, `workspace/runs/parallel-delivery/recon/20260512T184119Z/` +- `evidence`: `workspace/runs/parallel-delivery/recon/20260512T184119Z/implementation-map.json`, `workspace/runs/parallel-delivery/recon/20260512T184119Z/implementation-map.md`, `workspace/runs/parallel-delivery/recon/20260512T184119Z/owned-paths-plan.json`, `workspace/runs/parallel-delivery/recon/20260512T184119Z/recon-summary.md`, `workspace/runs/parallel-delivery/recon/20260512T184119Z/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-product-architecture-contract`, `2026-05-11-patch-swarm-product-rc-gate`, `2026-05-11-patch-swarm-first-run-clarity-ui` +- `corrects_record_id`: none + +#### Trigger + +The operator requested an append-only record based on the Call 2 reconnaissance findings so future Patch Swarm / Parallel Delivery implementation threads can quickly find the entrypoint, ownership map, and preservation guards. + +#### What Changed + +- Added this append-only reference record after the recon-only Call 2 artifacts were generated and validated. +- Captured the durable recon directory as `workspace/runs/parallel-delivery/recon/20260512T184119Z/`. +- Recorded that `parallel-delivery` is registered in `data/tools.json` and maps to `./scripts/parallel_delivery.py`. +- Recorded that all required related surfaces were represented: `parallel-delivery`, `patch-swarm`, `factory`, `workset`, `build`, `agent-work`, `proreq-light`, `temp`, `agent-pool-kick`, and `agent-processes`. +- Recorded that 28 dirty paths were captured and marked `preserve_carefully` in `implementation-map.json`. + +#### What Worked + +- Recon stayed read-only for product/source files and wrote only under the timestamped recon run directory. +- `cento gather-context --no-remote`, `cento tools`, `cento docs`, targeted `cento docs` commands, registry scans, tracked-file scans, JSON validation, surface checks, and Markdown checks were captured. +- The generated `owned-paths-plan.json` gives future slices concrete candidate owned paths, read-only dependencies, dirty paths to preserve, expected artifacts, validation focus, and risk notes. +- No high-severity unresolved finding remained because the registered `parallel-delivery` entrypoint was found. + +#### What Did Not Work + +- The current working tree already contains broad dirty work, including registry, docs, UI, scripts, tests, and untracked files. Future threads must inspect before editing and preserve unrelated hunks. +- Untracked file contents were intentionally not scanned, so future work must not infer behavior from untracked paths without a fresh, explicit inspection. +- `data/tools.json` is dirty, so registry-related future calls must treat it as both source of truth and a preserve-carefully target. + +#### Useful Future References + +- Use `workspace/runs/parallel-delivery/recon/20260512T184119Z/implementation-map.json` for machine-readable surface, registry, dirty-file, and entrypoint data. +- Use `workspace/runs/parallel-delivery/recon/20260512T184119Z/owned-paths-plan.json` before assigning implementation slices or editing candidate paths. +- Use `workspace/runs/parallel-delivery/recon/20260512T184119Z/recon-summary.md` for a short operator-readable overview. +- Re-run dirty-state discovery before every future implementation call; this recon is a baseline, not a license to edit stale ownership assumptions. +- Preserve the integration rule: `parallel-delivery` should route to existing Factory, Workset, Build, ProReq-light, and Safe Integrator surfaces rather than creating a duplicate workflow. + +#### Next Steps + +- Start future implementation with `run-artifact-schema`, then `parallel-delivery-cli-contract`, using the recon `owned-paths-plan.json` as the ownership baseline. +- Before any registry, docs, UI, or test change, inspect the corresponding dirty file and preserve unrelated hunks. +- Keep Taskstream visibility routed through existing MCP or `cento agent-work`; do not add direct database writes. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `repo-recon`, `path-ownership`, `dirty-work`, `factory`, `workset`, `build`, `agent-work`, `validation` + +### 2026-05-12T19:30:08Z - Pro Loop Fresh-Context Skill + +- `record_id`: 2026-05-12-pro-loop-fresh-context-skill +- `actor`: codex +- `scope`: codex-skills, fresh-context-prompts, cento-native, operator-workflow, validation-evidence +- `status`: implemented +- `artifacts_changed`: `/home/alice/.codex/skills/pro-loop/SKILL.md`, `docs/ai-self-improvement-log.md`, `workspace/runs/pro-loop/20260512T192952Z/validation.log` +- `evidence`: `cento gather-context --no-remote`, `cento tools`, `cento docs`, `git status --short --branch`, `workspace/runs/context/cento-ai-context-brief.md`, `workspace/runs/pro-loop/20260512T192952Z/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-repo-recon-baseline`, `2026-05-12-patch-swarm-product-architecture-contract` +- `corrects_record_id`: none + +#### Trigger + +The operator provided a fresh-context implementation plan to create a local Codex `pro-loop` skill for future Cento implementation prompts that must start from context gathering, explicit prompt intake, discovery-before-edits, validation evidence, dirty-work preservation, and append-only logging. + +#### What Changed + +- Added `/home/alice/.codex/skills/pro-loop/SKILL.md` with the requested `name: pro-loop` frontmatter and trigger description. +- Defined the required context pass: read the self-improvement log, run `cento gather-context --no-remote`, `cento tools`, `cento docs`, capture `git status --short --branch`, and read `workspace/runs/context/cento-ai-context-brief.md` when present. +- Required an explicit prompt file path and forbade defaulting to the newest Telegram download. +- Captured discovery-before-edits, Cento-native routing, dirty-work preservation, Taskstream/Redmine database safety, secret handling, workspace evidence, validation, and closeout rules. +- Kept v1 to `SKILL.md` only; no `agents/openai.yaml` or repo docs were added. + +#### What Worked + +- The skill file was created outside the repo-local dirty surfaces and validation confirmed the required metadata and workflow phrases. +- `cento gather-context --no-remote`, `cento tools`, and `cento docs` were available locally and supported the expected context pass. +- Validation evidence was written under `workspace/runs/pro-loop/20260512T192952Z/`. + +#### What Did Not Work + +- The new local skill may not appear in the active skill list until a future Codex session reloads local skills. +- No external implementation prompt was executed because this run did not include an explicit prompt file path from the operator. + +#### Next Steps + +- Invoke `pro-loop` with an explicit prompt path, such as a specific file under `/home/alice/Downloads/Telegram Desktop/`, when the next fresh-context Cento implementation prompt is ready. +- For Patch Swarm Call 3, inspect the dirty `data/tools.json`, `docs/patch-swarm.md`, `templates/agent-work-app/*`, and `tests/test_patch_swarm.py` targets before editing and preserve unrelated hunks. +- Continue appending self-improvement records for Cento workflow changes with exact evidence paths and validation results. + +#### Tags + +`cento-native`, `self-improvement`, `codex-skill`, `pro-loop`, `fresh-context`, `operator-workflow`, `dirty-work`, `validation` + +### 2026-05-12T20:42:04Z - Patch Swarm Call 4 Artifact Schema + +- `record_id`: 2026-05-12-patch-swarm-call-4-artifact-schema +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, artifact-schema, run-state-model, fixture-validation, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_artifacts.py`, `tests/test_parallel_delivery_artifact_schema.py`, `docs/parallel-delivery/patch-swarm-artifacts.md`, `data/tools.json`, `workspace/runs/parallel-delivery/schema-fixture/`, `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/raw/`, `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/resolved-schema-implementation.md`, `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/resolved-schema-tool.txt`, `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/json/schema-summary.json`, `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/json/schema-fixture-validation.json`, `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/schema-check-report.md`, `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/summary.md`, `workspace/runs/parallel-delivery/call-4-artifact-schema/20260512T202925Z/validation/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-product-architecture-contract`, `2026-05-12-patch-swarm-repo-recon-baseline`, `2026-05-12-pro-loop-fresh-context-skill` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 4 and asked to implement the repo-native artifact schema and run state model without live planning, worker dispatch, patch application, or full integration runtime. + +#### What Changed + +- Added `scripts/parallel_delivery_artifacts.py` as the adjacent standard-library schema helper for the existing `parallel-delivery` / Patch Swarm surface. +- Defined schema versioning, artifact type constants, run states, task states, lease states, run and task transition validation, compatibility rules, deterministic JSON writing, Markdown metadata validation, unsafe path rejection, evidence pointer checks, and run-directory validation. +- Added validators for `run.json`, `request.md`, `context-pack.json`, `split-plan.json`, `task-graph.json`, `path-leases.json`, `worker-prompts/`, `worker-ledger.jsonl`, `patch-bundles/`, `integration-plan.json`, `integration-receipt.json`, `validation.json`, `validation-report.md`, `release-candidate.json`, `release-notes.md`, and `start-here.md`. +- Added CLI actions: `write-fixture`, `validate-run`, and `print-schema-summary`. +- Generated deterministic fixture artifacts under `workspace/runs/parallel-delivery/schema-fixture/`. +- Added `tests/test_parallel_delivery_artifact_schema.py` with valid and invalid artifact coverage. +- Added `docs/parallel-delivery/patch-swarm-artifacts.md` and a minimal `data/tools.json` note so `cento docs parallel-delivery` points to the schema doc/helper without adding a new registered command. + +#### What Worked + +- Discovery confirmed `parallel-delivery` is registered to `./scripts/parallel_delivery.py` and no dedicated artifact/schema helper existed, so the implementation remained adjacent and additive. +- `python3 scripts/parallel_delivery_artifacts.py print-schema-summary --json` produced parseable schema summary JSON with required artifact, run-state, and task-state entries. +- `write-fixture` generated the full required fixture bundle, including worker prompt and patch bundle directory artifacts. +- `validate-run --json` returned `ok: true` for the fixture with no errors. +- Two fixture generations with the same fixed timestamp had no diff. +- `pytest -q tests/test_parallel_delivery_artifact_schema.py` passed with 15 tests. +- Existing Patch Swarm / parallel-delivery test selection passed with 30 tests. +- `cento tools`, `cento docs parallel-delivery`, registry JSON validation, and `make check` passed. + +#### What Did Not Work + +- The discovery command hit zsh unmatched-glob behavior when no Call 3 directory existed; the run continued and recorded `latest_call3=`. No Call 3 evidence was inferred. +- The first existing-test invocation passed a newline-separated test list as one zsh scalar argument. The same discovered files were rerun with explicit line splitting and passed. + +#### Next Steps + +- Use these schemas in the next runtime slice when request intake / ProReq packet generation starts writing durable run directories. +- Keep live planning, worker dispatch, patch application, and full integration runtime as separate gated calls. +- Preserve the rule that Patch Swarm runtime behavior routes through existing `parallel-delivery`, Factory, Workset, Build, and Safe Integrator surfaces instead of creating a duplicate workflow. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `artifact-schema`, `run-state-model`, `fixture`, `validation`, `docs`, `tests` + +### 2026-05-12T21:05:29Z - Patch Swarm Call 5 Request Splitter Planner + +- `record_id`: 2026-05-12-patch-swarm-call-5-request-splitter-planner +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, request-splitter, planner, task-graph, proreq, manual-import, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_planner.py`, `scripts/parallel_delivery.py`, `tests/test_parallel_delivery_planner.py`, `docs/parallel-delivery/patch-swarm-planner.md`, `docs/patch-swarm.md`, `data/tools.json`, `workspace/runs/parallel-delivery/planner-fixture/`, `workspace/runs/parallel-delivery/call-5-request-splitter/20260512T204920Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/call-5-request-splitter/20260512T204920Z/raw/`, `workspace/runs/parallel-delivery/call-5-request-splitter/20260512T204920Z/resolved-planner-implementation.md`, `workspace/runs/parallel-delivery/call-5-request-splitter/20260512T204920Z/resolved-planner-tool.txt`, `workspace/runs/parallel-delivery/call-5-request-splitter/20260512T204920Z/planner-summary.json`, `workspace/runs/parallel-delivery/call-5-request-splitter/20260512T204920Z/planner-check-report.md`, `workspace/runs/parallel-delivery/call-5-request-splitter/20260512T204920Z/summary.md`, `workspace/runs/parallel-delivery/call-5-request-splitter/20260512T204920Z/validation/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-product-architecture-contract`, `2026-05-12-patch-swarm-repo-recon-baseline`, `2026-05-12-pro-loop-fresh-context-skill`, `2026-05-12-patch-swarm-call-4-artifact-schema` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 5 and asked to implement the request splitter and bounded 100-task planner using `/home/alice/Downloads/Telegram Desktop/message (3).txt` as the next prompt. + +#### What Changed + +- Added `scripts/parallel_delivery_planner.py` as the adjacent standard-library planner helper for the existing `parallel-delivery` / Patch Swarm surface. +- Added planner modes: `fixture`, `no-model`, `proreq`, and `manual-import`. +- Added validation for candidate target bounds, max parallel bounds, task IDs, lanes, risk tiers, worker profiles, unsafe paths, non-overlapping owned paths, dependencies, acyclic task graphs, and parallel group width. +- Added `cento parallel-delivery patch-swarm split` to the existing registered CLI surface without adding an unrelated root command. +- Updated `cento parallel-delivery patch-swarm e2e --fixture` so the requested 100-candidate e2e path also writes `workspace/runs/parallel-delivery/planner-fixture/`. +- Added `docs/parallel-delivery/patch-swarm-planner.md` and linked it from the Patch Swarm product spec and `data/tools.json`. +- Added `tests/test_parallel_delivery_planner.py`. + +#### What Worked + +- Discovery confirmed `parallel-delivery` is registered to `./scripts/parallel_delivery.py`, `patch-swarm split` did not exist yet, and Call 4's artifact helper was available for stable JSON, task states, and safe path validation. +- The requested e2e command produced parseable JSON and generated a 100-task planner fixture with `split-plan.json`, `task-graph.json`, `task-contracts/task-0001.md`, `task-contracts/task-0100.md`, ProReq prompt artifacts, `planner-report.md`, and `start-here.md`. +- Fixture mode generated exactly 5, 20, and 100 tasks in validation. +- No-model mode planned a small help-text request with `candidate_target=100` and `candidate_count=3`, proving the cap is not blindly filled. +- ProReq mode emitted planning manifest and prompt artifacts with `live_pro_called=false`. +- Manual import accepted a valid generated plan and rejected overlapping owned paths with a nonzero exit code. +- `pytest -q tests/test_parallel_delivery_planner.py` passed with 14 tests. +- Existing Patch Swarm / parallel-delivery tests passed with 44 tests. +- `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, `cento docs parallel-delivery`, and `make check` passed. + +#### What Did Not Work + +- The first evidence-summary write command had a shell quoting error around a multi-line `jq` filter. It was rerun using a quoted bash heredoc and succeeded. +- The planner intentionally does not perform live ChatGPT Pro calls, worker dispatch, patch application, or Taskstream mutation. Those remain later explicit calls. + +#### Next Steps + +- Use the planner output as the input contract for later prompt emission, path lease materialization, worker bundle collection, and integration-plan calls. +- Keep `candidate_target` documented as a cap outside fixture mode. +- Keep live Pro and worker dispatch behind separate explicit gates with budget, secret, and evidence controls. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `request-splitter`, `planner`, `task-graph`, `proreq`, `manual-import`, `validation`, `docs`, `tests` + +### 2026-05-12T23:47:38Z - Patch Swarm Call 8 Codex Worker Packets + +- `record_id`: 2026-05-12-patch-swarm-call-8-codex-worker-packets +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, codex-worker-packets, path-leases, workset, build, agent-work, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_codex_packets.py`, `scripts/parallel_delivery.py`, `tests/test_parallel_delivery_codex_worker_packets.py`, `docs/parallel-delivery/patch-swarm-codex-worker-packets.md`, `docs/patch-swarm.md`, `data/tools.json`, `workspace/runs/parallel-delivery/codex-packets-fixture/`, `workspace/runs/parallel-delivery/call-8-codex-worker-packets/20260512T234738Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/call-8-codex-worker-packets/20260512T234738Z/raw/`, `workspace/runs/parallel-delivery/call-8-codex-worker-packets/20260512T234738Z/resolved-codex-packet-implementation.md`, `workspace/runs/parallel-delivery/call-8-codex-worker-packets/20260512T234738Z/resolved-codex-packet-tool.txt`, `workspace/runs/parallel-delivery/call-8-codex-worker-packets/20260512T234738Z/build-workset-packet-compatibility.md`, `workspace/runs/parallel-delivery/call-8-codex-worker-packets/20260512T234738Z/json/write-codex-packets-fixture.json`, `workspace/runs/parallel-delivery/call-8-codex-worker-packets/20260512T234738Z/json/validate-codex-packets-fixture.json`, `workspace/runs/parallel-delivery/call-8-codex-worker-packets/20260512T234738Z/validation/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-repo-recon-baseline`, `2026-05-12-patch-swarm-call-4-artifact-schema`, `2026-05-12-patch-swarm-call-5-request-splitter-planner` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 8 and asked to implement local-first Codex worker packet generation from split-plan, task-graph, and path-leases artifacts without live dispatch. + +#### What Changed + +- Added `scripts/parallel_delivery_codex_packets.py` with local policy output, fixture writing, packet generation, bundle validation, hash checks, path non-overlap checks, and secret-like value guards. +- Added `cento parallel-delivery patch-swarm worker-packets` to the existing `parallel-delivery` / `patch-swarm` CLI surface instead of adding a duplicate root workflow. +- Generated a deterministic 10-packet fixture covering builder, validator, docs-evidence, coordinator, and integrator lanes. +- Added `docs/parallel-delivery/patch-swarm-codex-worker-packets.md` and linked the command from the canonical Patch Swarm docs and registry metadata. +- Added focused tests for fixture shape, required packet sections, lane coverage, non-overlapping owned paths, shared read-only paths, packet hashes, secret-like value checks, validation output, and CLI JSON. + +#### What Worked + +- Discovery confirmed Build, Workset, Agent Work, lease, planner, and ChatGPT Pro prompt conventions. The Codex packet generator reuses Build patch bundle/handoff wording, Workset exclusive write path rules, and Agent Work lane/handoff conventions. +- `python3 scripts/parallel_delivery_codex_packets.py print-policy --json`, `write-fixture`, and `validate-bundle` produced parseable JSON and passed jq checks. +- The fixture generated `codex-packet-bundle.json`, `codex-packet-index.json`, `codex-packet-index.md`, 10 packet Markdown files, patch-bundle/handoff directories, validation artifacts, and `start-here.md`. +- `pytest -q tests/test_parallel_delivery_codex_worker_packets.py` passed with 8 tests. +- Existing related tests passed with 83 tests. +- `make check` passed. + +#### What Did Not Work + +- The initial discovery output was very large and some Call 6/7 files were only visible after later inspection; the implementation adjusted by preserving those existing lease and prompt helpers and adding a separate Codex worker packet helper. + +#### Next Steps + +- Later Patch Swarm slices can collect returned worker patch bundles and feed them through deterministic validation and Safe Integrator paths. +- Keep this generator local-only; any live worker launch should remain a separate explicit dispatch slice with budget, lease, and evidence gates. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `codex-worker-packets`, `path-leases`, `workset`, `build`, `agent-work`, `validation`, `docs`, `tests` + +### 2026-05-12T23:39:39Z - Patch Swarm Call 6 Path Leasing + +- `record_id`: 2026-05-12-patch-swarm-call-6-path-leasing +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, path-leasing, workset-compatibility, operation-validation, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_leases.py`, `scripts/parallel_delivery.py`, `tests/test_parallel_delivery_path_leases.py`, `docs/parallel-delivery/patch-swarm-leasing.md`, `docs/patch-swarm.md`, `data/tools.json`, `workspace/runs/parallel-delivery/lease-fixture/`, `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/raw/`, `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/resolved-lease-implementation.md`, `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/resolved-lease-tool.txt`, `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/workset-compatibility.md`, `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/lease-summary.json`, `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/lease-check-report.md`, `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/summary.md`, `workspace/runs/parallel-delivery/call-6-path-leasing/20260512T233939Z/validation/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-call-4-artifact-schema`, `2026-05-12-patch-swarm-call-5-request-splitter-planner` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 6 and asked to implement exclusive path leasing plus Workset-compatible validation for Patch Swarm. + +#### What Changed + +- Added `scripts/parallel_delivery_leases.py` for deterministic lease IDs, path normalization, protected/guarded path checks, dirty target warnings, dependency gates, safe parallel groups, Workset compatibility artifacts, and planned operation validation. +- Added `cento parallel-delivery patch-swarm leases` and `cento parallel-delivery patch-swarm validate-leases` under the existing Patch Swarm CLI surface. +- Generated the deterministic lease fixture at `workspace/runs/parallel-delivery/lease-fixture/`. +- Added conflict examples for exact overlap, parent/child overlap, protected paths, unsafe delete, unowned rename, binary patch, broad cleanup, and lockfile outside contract. +- Added `docs/parallel-delivery/patch-swarm-leasing.md` and linked it from `docs/patch-swarm.md` and `data/tools.json`. +- Added `tests/test_parallel_delivery_path_leases.py`. + +#### What Worked + +- Discovery confirmed Workset v1 owns exclusive `write_paths`, dependency checks, and overlap validation, while Build owns patch safety concepts such as protected paths, lockfiles, binary patches, deletes, renames, and dirty-owned checks. +- `path-leases.json` was generated with 5 deterministic leases, 6 dependency/manual/dirty gates, 4 parallel groups, and one dirty target warning for `data/tools.json`. +- `validate --run-dir workspace/runs/parallel-delivery/lease-fixture --json` returned `ok: true` with no errors. +- `check-operations` returned `ok: true` for the valid planned operations fixture. +- All conflict examples exited nonzero under `validate --path-leases`. +- The Workset-compatible manifest subset passed the supported positional command `cento workset check WORKSET --allow-creates --json`. +- `pytest -q tests/test_parallel_delivery_path_leases.py` passed with 20 tests. +- Existing related Workset / Build / Patch Swarm / parallel-delivery tests passed with 75 tests. +- `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, `cento docs parallel-delivery`, `cento docs workset`, `cento docs build`, and `make check` passed. + +#### What Did Not Work + +- The initial discovery shell exited early under strict shell settings after the context pass. Missing discovery commands were rerun before source edits and evidence was captured in the same Call 6 directory. +- The common Workset compatibility validation shape `cento workset check --manifest ...` is not supported by the current Workset CLI and exited with code 2. The supported positional Workset check was run and passed; the gap is recorded in `workset-compatibility.md`. + +#### Next Steps + +- Require a successful lease validation before prompt emission writes worker prompt bundles. +- Validate future patch bundle metadata against `path-leases.json` before integration planning. +- Keep actual patch application behind Build / Factory / Safe Integrator gates. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `path-leasing`, `workset`, `build`, `operation-validation`, `fixture`, `validation`, `docs`, `tests` + +### 2026-05-12T23:58:14Z - Patch Swarm Call 7 ProReq Prompt Bundles + +- `record_id`: 2026-05-12-patch-swarm-call-7-proreq-prompt-bundles +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, proreq, chatgpt-pro-prompts, temp-bridge, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_prompts.py`, `scripts/parallel_delivery.py`, `tests/test_parallel_delivery_proreq_prompts.py`, `docs/parallel-delivery/patch-swarm-proreq-prompts.md`, `docs/patch-swarm.md`, `data/tools.json`, `workspace/runs/parallel-delivery/proreq-fixture/`, `workspace/runs/temp/chatgpt-pro/proreq-fixture/`, `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/raw/`, `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/resolved-prompt-implementation.md`, `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/resolved-prompt-tool.txt`, `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/proreq-temp-compatibility.md`, `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/prompt-summary.json`, `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/prompt-check-report.md`, `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/summary.md`, `workspace/runs/parallel-delivery/call-7-proreq-prompt-generator/20260512T234222Z/validation/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-call-5-request-splitter-planner`, `2026-05-12-patch-swarm-call-6-path-leasing` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 7 and asked for a local-first ProReq / ChatGPT Pro prompt bundle generator that turns run artifacts into one master prompt, lane and task-cluster prompts, a prompt index, temp bridge artifacts, validation, docs, tests, and durable evidence. + +#### What Changed + +- Added `scripts/parallel_delivery_prompts.py` with local prompt bundle generation, deterministic fixture writing, prompt validation, JSON output, lane filtering, secret-like text redaction, and temp mirror support. +- Added `cento parallel-delivery patch-swarm prompts` under the existing Patch Swarm CLI surface without adding a new root workflow. +- Generated the 20-prompt fixture at `workspace/runs/parallel-delivery/proreq-fixture/` and the local temp mirror at `workspace/runs/temp/chatgpt-pro/proreq-fixture/`. +- Added `docs/parallel-delivery/patch-swarm-proreq-prompts.md` and linked it from the Patch Swarm product spec and `data/tools.json`. +- Added `tests/test_parallel_delivery_proreq_prompts.py`. + +#### What Worked + +- Discovery confirmed `proreq-light`, `parallel-delivery`, `patch-swarm`, and `cento temp` surfaces before editing. +- `write-fixture --count 20` generated exactly 20 prompts with one master prompt first and `prompt-0020-evidence.md` last. +- `write-fixture --count 15` generated exactly 15 prompts. +- `write-fixture --lane builder --count 15` generated a builder-scoped bundle with a master overview prompt. +- Prompt validation passed with matching prompt hashes, required prompt sections, the 11-part Codex output schema, evidence requirements, validation plans, and safety rules. +- Secret-like request text was redacted in tests and no live AI/API/worker calls are required by default. +- The CLI route `cento parallel-delivery patch-swarm prompts --json` emitted parseable JSON. +- `pytest -q tests/test_parallel_delivery_proreq_prompts.py` passed with 11 tests. +- Existing related Patch Swarm / parallel-delivery / ProReq / prompt tests passed with 83 tests. +- `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, `cento docs parallel-delivery`, and `make check` passed. + +#### What Did Not Work + +- `cento temp run --file PATH` and positional prompt-file forms are not supported by the current temp CLI. The generator writes the default temp command to point at the generated current prompt, and `cento temp run --dry-run --no-copy` returned 0. +- An initial temp validation wrapper used zsh while reading Bash `PIPESTATUS`; it failed before recording return codes and was rerun under Bash successfully. + +#### Next Steps + +- Use `cento parallel-delivery patch-swarm prompts --run-dir RUN --count 15|20 --lane all --copy-to-temp --json` after split planning and path leasing are available for a run. +- Keep ChatGPT Pro execution as an operator copy/paste workflow unless a later explicit bridge is designed and validated. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `proreq`, `chatgpt-pro`, `prompt-bundle`, `temp-bridge`, `fixture`, `validation`, `docs`, `tests` + +### 2026-05-13T00:17:27Z - Patch Swarm Call 12 Deterministic Validation E2E + +- `record_id`: 2026-05-13-patch-swarm-call-12-validation-e2e +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, deterministic-validation, fixture-e2e, simulated-workers, dry-run-integration, release-candidate-evidence, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_validation_e2e.py`, `scripts/parallel_delivery.py`, `tests/test_parallel_delivery_validation_e2e.py`, `docs/parallel-delivery/patch-swarm-validation-e2e.md`, `docs/patch-swarm.md`, `data/tools.json`, `workspace/runs/parallel-delivery/e2e-fixture/fixture-5-workers/`, `workspace/runs/parallel-delivery/e2e-fixture/fixture-100-agents/`, `workspace/runs/parallel-delivery/call-12-validation-e2e/20260513T000316Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/call-12-validation-e2e/20260513T000316Z/raw/`, `workspace/runs/parallel-delivery/call-12-validation-e2e/20260513T000316Z/resolved-validation-e2e-implementation.md`, `workspace/runs/parallel-delivery/call-12-validation-e2e/20260513T000316Z/resolved-validation-e2e-tool.txt`, `workspace/runs/parallel-delivery/call-12-validation-e2e/20260513T000316Z/validation-e2e-summary.json`, `workspace/runs/parallel-delivery/call-12-validation-e2e/20260513T000316Z/validation-e2e-check-report.md`, `workspace/runs/parallel-delivery/call-12-validation-e2e/20260513T000316Z/summary.md`, `workspace/runs/parallel-delivery/call-12-validation-e2e/20260513T000316Z/validation/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-call-5-request-splitter-planner`, `2026-05-12-patch-swarm-call-6-path-leasing`, `2026-05-12-patch-swarm-call-7-proreq-prompt-bundles` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 12 and asked to strengthen deterministic validation plus the product-quality 100-agent fixture E2E without live Pro, OpenAI API, Codex dispatch, MCP mutation, Taskstream/Redmine direct writes, or real patch application. + +#### What Changed + +- Added `scripts/parallel_delivery_validation_e2e.py` for deterministic fixture E2E runs, validation policy, existing-run validation, simulated worker batches, patch bundle safety validation, malformed artifact rejection, dry-run integration receipts, release-candidate evidence, and stable JSON output. +- Updated `cento parallel-delivery patch-swarm e2e` to delegate fixture dry-run CLI runs to the new validation E2E engine while preserving the older direct programmatic e2e path. +- Added required E2E CLI flags: `--run-root`, `--dry-run`, `--fixed-timestamp`, and `--include-unsafe-fixture`. +- Added `docs/parallel-delivery/patch-swarm-validation-e2e.md` and linked it from Patch Swarm docs and the tool registry. +- Added `tests/test_parallel_delivery_validation_e2e.py`. + +#### What Worked + +- Discovery probed the existing e2e command before edits; it exited 1 and produced a legacy blocked run without the requested validation fixture summary. +- The exact acceptance command `cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json` returned `ok: true`. +- The deterministic 5-task fixture passed and wrote validation, integration, release-candidate, command log, and start-here artifacts. +- The deterministic 100-task fixture passed with 100 tasks, 100 leases, 100 worker packets, 20 simulated worker batches, 100 accepted patch bundles, and one rejected unsafe bundle. +- Unsafe out-of-lease and malformed missing-run-id artifacts were rejected and recorded as passing negative checks. +- The integration plan excluded rejected bundles, and the dry-run integration receipt included accepted bundles only. +- `pytest -q tests/test_parallel_delivery_validation_e2e.py` passed with 8 tests. +- Existing related Patch Swarm / parallel-delivery / validation / integration tests passed with 123 tests. +- `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, `cento docs parallel-delivery`, `cento docs build`, `cento docs workset`, and `make check` passed. + +#### What Did Not Work + +- The pre-edit e2e probe returned `status: blocked` and `validation: failed`; this was the gap this call addressed. +- The fixture release-candidate evidence is intentionally local and dry-run only. It does not apply patches or claim a production release. + +#### Next Steps + +- Future live/apply paths can consume the deterministic validation summary only after explicit Factory/Safe Integrator gates. +- Keep fixture workers as artifact writers unless a later call explicitly designs a live dispatch bridge with budget and safety gates. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `validation-e2e`, `100-agents`, `simulated-workers`, `dry-run-integration`, `release-candidate`, `fixture`, `docs`, `tests` + +### 2026-05-13T00:06:00Z - Patch Swarm Call 9 Patch Bundle Collection + +- `record_id`: 2026-05-13-patch-swarm-call-9-patch-bundle-collection +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, patch-bundles, build-safety, leases, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_patch_bundles.py`, `scripts/parallel_delivery/patch_bundle_fixture.py`, `scripts/parallel_delivery.py`, `tests/test_patch_bundle_validation.py`, `tests/test_patch_bundle_collector.py`, `docs/parallel-delivery/patch-bundle-validation.md`, `docs/patch-swarm.md`, `data/tools.json`, `README.md`, `Makefile`, `workspace/runs/parallel-delivery/patch-bundle-fixture/`, `workspace/runs/parallel-delivery/call-9-patch-bundles/20260512T235900Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/patch-bundle-fixture/patch-bundle-report.json`, `workspace/runs/parallel-delivery/patch-bundle-fixture/patch-bundle-report.md`, `workspace/runs/parallel-delivery/patch-bundle-fixture/receipts/`, `workspace/runs/parallel-delivery/patch-bundle-fixture/collect.stdout`, `workspace/runs/parallel-delivery/patch-bundle-fixture/validation-summary.txt`, `workspace/runs/parallel-delivery/call-9-patch-bundles/20260512T235900Z/summary.md`, `workspace/runs/parallel-delivery/call-9-patch-bundles/20260512T235900Z/validation/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-call-6-path-leasing`, `2026-05-12-patch-swarm-call-7-proreq-prompt-bundles` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 9 and asked for a local-first patch bundle collector and safety validator that accepts patch bundles or evidence-only results, rejects unsafe bundles before integration, and writes deterministic receipts and aggregate evidence. + +#### What Changed + +- Added `scripts/parallel_delivery_patch_bundles.py` with `cento.patch_bundle.v1`, lease manifest, receipt, and collection report support. +- Added `cento parallel-delivery patch-bundles validate` and `cento parallel-delivery patch-bundles collect` under the existing `parallel-delivery` surface. +- Reused `cento_build` path matching and lockfile helpers for existing Build safety policy where practical. +- Added deterministic fixture input generation through `scripts/parallel_delivery/patch_bundle_fixture.py`. +- Generated the required fixture under `workspace/runs/parallel-delivery/patch-bundle-fixture/`. +- Added docs, README entries, Makefile targets, registry metadata, and targeted tests. + +#### What Worked + +- Discovery found the root CLI facade at `scripts/cento.sh`, `parallel-delivery` at `scripts/parallel_delivery.py`, and Build patch safety helpers in `scripts/cento_build.py`. +- The fixture generated 14 bundle manifests and collector receipts: 2 accepted, 12 rejected, and 1 accepted evidence-only result. +- Required rejection codes were present for outside lease, protected path, `.env.mcp` / local secret path, traversal, absolute path, symlink, submodule, binary patch, undeclared delete, unowned rename, broad lockfile change, and fake secret-looking added content. +- `cento parallel-delivery patch-bundles collect --json` and `validate --json` emitted parseable JSON. +- `pytest -q tests/test_patch_bundle_validation.py tests/test_patch_bundle_collector.py` passed with 13 tests. +- Related Patch Swarm tests passed with 68 tests. +- Full `pytest -q tests` passed with 246 tests. +- `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, `cento docs parallel-delivery`, `make test-patch-bundles`, and `make patch-bundle-fixture` passed. + +#### What Did Not Work + +- The prompt's fallback command form used `python -m cento`, but this host has `python3` and `scripts/cento.sh`, not a `python` binary or package entrypoint. Validation used the discovered repo-native `./scripts/cento.sh` route and `python3`. + +#### Next Steps + +- Feed accepted receipts into a later integration planning slice without applying patches in the collector. +- Reuse these receipts as the mechanical gate before Factory / Safe Integrator promotion. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `patch-bundles`, `build-safety`, `leases`, `fixture`, `validation`, `docs`, `tests` + +### 2026-05-13T00:14:09Z - Parallel Delivery Call 11 Safe Apply And Release Candidate + +- `record_id`: 2026-05-13-parallel-delivery-call-11-safe-apply-release-candidate +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, safe-apply, integration-receipts, rollback-metadata, release-candidate, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_release_candidate.py`, `scripts/parallel_delivery/release_candidate_fixture.py`, `scripts/parallel_delivery.py`, `tests/test_parallel_delivery_safe_apply.py`, `tests/test_parallel_delivery_release_candidate.py`, `docs/parallel-delivery/release-candidate-safe-apply.md`, `docs/patch-swarm.md`, `data/tools.json`, `Makefile`, `workspace/runs/parallel-delivery/release-candidate-fixture/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/release-candidate-fixture/dry-run/apply-report.json`, `workspace/runs/parallel-delivery/release-candidate-fixture/apply/apply-report.json`, `workspace/runs/parallel-delivery/release-candidate-fixture/apply/release-candidate.json`, `workspace/runs/parallel-delivery/release-candidate-fixture/apply/release-notes.md`, `workspace/runs/parallel-delivery/release-candidate-fixture/apply/rollback-metadata.json`, `workspace/runs/parallel-delivery/release-candidate-fixture/validation-summary.txt` +- `checked_prior_records`: `2026-05-13-patch-swarm-call-9-patch-bundle-collection` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Parallel Delivery Call 11 and asked for safe patch application, rollback metadata, and release-candidate creation from accepted integration receipts without adding a duplicate root workflow. + +#### What Changed + +- Added a `parallel-delivery` release-candidate create route that verifies accepted integration receipts and accepted bundle receipts before any dry-run or apply step. +- Added deterministic apply-step receipts, aggregate apply reports, metadata-only rollback records, release-candidate JSON, release notes, and integrated diff output. +- Added a fixture generator that creates isolated local target inputs under `workspace/runs/parallel-delivery/release-candidate-fixture/`. +- Added targeted tests for receipt refusal, patch hash mismatch, dry-run no mutation, sequential apply, first-failure stopping, rollback metadata, final validation gating, and CLI JSON output. +- Added docs, registry metadata, and Makefile shortcuts. + +#### What Worked + +- Discovery found existing Build accepted-receipt apply checks and Factory Safe Integrator worktree/release evidence conventions. +- The new route stays under `cento parallel-delivery release-candidate create`. +- Dry-run applies zero patches and writes rollback metadata. +- Apply mode requires an isolated target worktree, applies accepted bundles sequentially, validates after each bundle, and writes release-candidate artifacts only after final validation passes. +- Targeted tests passed. + +#### What Did Not Work + +- The prompt's validation snippets used `python`, but this host exposes `python3`. The safe-apply runner maps leading `python ...` validation commands to `python3 ...` when no `python` binary exists. + +#### Next Steps + +- Feed accepted Call 9/10 bundle collection receipts into this release-candidate layer when a real Patch Swarm integration receipt is available. +- Optionally add a Factory adapter that converts Factory integration state into `cento.parallel_delivery.integration_receipt.v1`. + +#### Tags + +`cento-native`, `patch-swarm`, `parallel-delivery`, `safe-apply`, `rollback-metadata`, `release-candidate`, `fixture`, `validation`, `docs`, `tests` + +### 2026-05-13T00:32:45Z - Patch Swarm Outage Recovery Validation Gate + +- `record_id`: 2026-05-13-patch-swarm-outage-recovery-validation-gate +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, validation, agent-work, outage-recovery, dirty-worktree-preservation +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery.py`, `tests/test_parallel_integration_train.py`, `workspace/runs/parallel-delivery/outage-recovery/20260513T002709Z/`, `workspace/runs/parallel-delivery/e2e-fixture/fixture-e2e-20260513T003140Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/outage-recovery/20260513T002709Z/`, `python3 -m pytest -q tests/test_parallel_integration_train.py`, `python3 -m pytest -q tests/test_parallel_delivery_artifact_schema.py tests/test_parallel_delivery_planner.py tests/test_parallel_delivery_path_leases.py tests/test_parallel_delivery_proreq_prompts.py tests/test_parallel_delivery_codex_worker_packets.py tests/test_patch_bundle_validation.py tests/test_patch_bundle_collector.py tests/test_parallel_delivery_release_candidate.py tests/test_parallel_delivery_safe_apply.py tests/test_parallel_delivery_validation_e2e.py tests/test_parallel_integration_train.py tests/test_patch_swarm.py`, `cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --run-root workspace/runs/parallel-delivery/e2e-fixture --json`, `cento parallel-delivery validate --json`, `cento parallel-delivery validate --run-dir workspace/runs/parallel-delivery/e2e-fixture/fixture-e2e-20260513T003140Z --json`, `cento tools`, `cento docs parallel-delivery`, `make check` +- `checked_prior_records`: `2026-05-13-patch-swarm-call-9-patch-bundle-collection`, `2026-05-13-parallel-delivery-call-11-safe-apply-release-candidate`, `2026-05-13-patch-swarm-call-12-validation-e2e` +- `corrects_record_id`: none + +#### Trigger + +The operator reported an outage after OOO/i3 disruption with several Codex agents likely interrupted, many dirty files, and missing context around Patch Swarm Calls 9-13. + +#### What Changed + +- Captured a recovery bundle with git status, binary diff, untracked inventory, worktrees, process probes, agent-work hygiene, and dirty-path classification before source edits. +- Fixed top-level `cento parallel-delivery validate` and `status` so no-arg runs ignore non-run roots such as `outage-recovery`, `recovery-smoke`, and fixture directories. +- Added explicit Patch Swarm fixture E2E validation dispatch for `cento parallel-delivery validate --run-dir PATH` when PATH is an E2E run directory or contains one. +- Added regression tests covering noisy recovery roots and explicit fixture E2E validation. + +#### What Worked + +- `cento agent-work-hygiene` and `cento agent-processes --once` showed untracked interactive Codex sessions but no managed worker session that needed harvesting. +- The fixed no-arg `cento parallel-delivery validate --json` now selects the last valid legacy run, not a fresh recovery artifact directory. +- Explicit fixture validation passed for `workspace/runs/parallel-delivery/e2e-fixture/fixture-e2e-20260513T003140Z`. +- The 100-candidate fixture E2E passed with 100 accepted patch bundles, one rejected unsafe bundle, 20 simulated worker batches, and dry-run integration only. +- Focused tests passed with 127 tests, and `make check` passed. + +#### What Did Not Work + +- The initial top-level validation selected a fresh `recovery-smoke` directory and failed legacy ProReq schema checks before this fix. +- A broad temporary secret scan pattern matched normal `task-*` text; the stricter API-key/private-key scan found no secret token values. +- Agent Processes still reports untracked interactive Codex sessions; these were preserved rather than killed. + +#### Next Steps + +- Resume Patch Swarm at Call 13 only after the dirty tree is intentionally packaged or committed. +- Keep live Pro/API/worker dispatch frozen until fixture validation remains green and the operator deliberately opts in. +- Split unrelated Industrial OS work from the Patch Swarm recovery scope before review. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `outage-recovery`, `validation`, `dirty-worktree`, `agent-work-hygiene`, `fixture`, `tests` + +### 2026-05-13T00:29:44Z - Patch Swarm Call 14 Worker Pool Status + +- `record_id`: 2026-05-13-patch-swarm-call-14-worker-pool-status +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, worker-pool, dry-run-dispatch, process-visibility, console-status, docs, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_worker_status.py`, `scripts/parallel_delivery.py`, `tests/test_parallel_delivery_worker_status.py`, `docs/parallel-delivery/patch-swarm-worker-status.md`, `docs/patch-swarm.md`, `data/tools.json`, `workspace/runs/parallel-delivery/worker-status-fixture/`, `workspace/runs/parallel-delivery/call-14-worker-status/20260513T002944Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/call-14-worker-status/20260513T002944Z/raw/`, `workspace/runs/parallel-delivery/call-14-worker-status/20260513T002944Z/resolved-worker-status-implementation.md`, `workspace/runs/parallel-delivery/call-14-worker-status/20260513T002944Z/process-visibility-compatibility.md`, `workspace/runs/parallel-delivery/call-14-worker-status/20260513T002944Z/json/`, `workspace/runs/parallel-delivery/call-14-worker-status/20260513T002944Z/validation/validation.log` +- `checked_prior_records`: `2026-05-12-patch-swarm-call-5-request-splitter-planner`, `2026-05-12-patch-swarm-call-6-path-leasing`, `2026-05-12-patch-swarm-call-7-proreq-prompt-bundles`, `2026-05-13-patch-swarm-call-12-validation-e2e`, `2026-05-13-patch-swarm-outage-recovery` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 14 and asked for bounded worker-pool planning plus process/status visibility that represents 100 candidate tasks without blindly launching 100 workers. + +#### What Changed + +- Added `scripts/parallel_delivery_worker_status.py` for local-only worker-pool planning, dry-run dispatch metadata, worker queue JSONL, worker status JSON, stale/risk indicators, process visibility metadata, Console/UI status JSON, fixture generation, and validation. +- Added `cento parallel-delivery patch-swarm dispatch` and `cento parallel-delivery patch-swarm worker-status` under the existing Patch Swarm CLI surface. +- Extended top-level `cento parallel-delivery status --run ... --run-root ... --json` so it can render the worker-status fixture without adding a root `cento patch-swarm` workflow. +- Generated `workspace/runs/parallel-delivery/worker-status-fixture/` with 100 candidate tasks, 20 planned batches, 5 active dry-run workers, 92 pending tasks, 1 completed task, 1 blocked task, 1 stale task, and 0 failed tasks. +- Added `docs/parallel-delivery/patch-swarm-worker-status.md` and linked it from the Patch Swarm product spec and tool registry. +- Added `tests/test_parallel_delivery_worker_status.py`. + +#### What Worked + +- Discovery inspected `parallel-delivery`, `agent-pool-kick`, `agent-processes`, `cluster`, and `bridge` surfaces before source edits. +- `agent-pool-kick` compatibility is recorded as dry-run metadata only; no external worker launch was performed. +- `agent-processes`, `cluster`, and `bridge` compatibility is read-only and platform guarded. +- The worker pool plan contains 100 tasks, max parallel agents 5, 20 batches, no duplicate batch membership, and no external launch. +- Queue ledger JSONL parsed and included queue, task, dispatch, dry-run skip, worker state, and snapshot events. +- `worker-status.json`, `console-status.json`, `stale-workers.json`, and `process-visibility.json` parsed and matched expected fixture counts. +- CLI JSON for dispatch, worker-status, and top-level status parsed. +- `pytest -q tests/test_parallel_delivery_worker_status.py` passed with 10 tests. +- Existing related tests passed with 123 tests. +- `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, `cento docs parallel-delivery`, and `make check` passed. + +#### What Did Not Work + +- The pre-edit `patch-swarm dispatch` and `patch-swarm worker-status` routes did not exist. The new routes are local dry-run/status routes only. +- Live dispatch remains unsupported in this layer and fails closed; future live launch must be owned by an explicit existing backend. + +#### Next Steps + +- Let future live-dispatch work consume `worker-pool-plan.json` and `dry-run-dispatch.json` only after explicit operator opt-in and backend validation. +- Keep Console/UI integrations pointed at `console-status.json` for compact status while retaining detailed ledgers separately. +- Preserve the current broad dirty worktree until it is intentionally packaged or committed. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `worker-pool`, `dry-run-dispatch`, `process-visibility`, `console-status`, `fixture`, `docs`, `tests` + +### 2026-05-13T00:46:30Z - Patch Swarm Call 15 Console Status Surface + +- `record_id`: 2026-05-13-patch-swarm-call-15-console-status +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, console, static-hub, status-json, agent-work-app, docs, tests, evidence +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_patch_swarm_console.py`, `scripts/parallel_delivery.py`, `scripts/parallel_delivery_validation_e2e.py`, `scripts/agent_work_app.py`, `templates/agent-work-app/app.js`, `tests/parallel_delivery/test_patch_swarm_console.py`, `docs/parallel-delivery/patch-swarm-console.md`, `docs/patch-swarm.md`, `README.md`, `data/tools.json`, `docs/tool-index.md`, `workspace/runs/parallel-delivery/console-fixture/fixture-console-25/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/console-fixture/fixture-console-25/start-here.html`, `workspace/runs/parallel-delivery/console-fixture/fixture-console-25/start-here.png`, `workspace/runs/parallel-delivery/console-fixture/fixture-console-25/console-data.json`, `workspace/runs/parallel-delivery/console-fixture/fixture-console-25/link-check.json`, `workspace/runs/parallel-delivery/console-fixture/fixture-console-25/console-validation-summary.json`, `workspace/runs/parallel-delivery/console-fixture/pytest-parallel-delivery-console.log` +- `checked_prior_records`: `2026-05-13-patch-swarm-call-12-validation-e2e`, `2026-05-13-patch-swarm-call-14-worker-status` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 15 and asked for an operator-visible run status surface that reads generated run artifacts, writes stable console data, renders a static hub, reuses existing Cento surfaces, validates evidence links, and preserves dirty work. + +#### What Changed + +- Added `scripts/parallel_delivery_patch_swarm_console.py` with artifact aggregation, normalized dataclasses, deterministic next-action rules, `console-data.json` writing, self-contained `start-here.html` rendering, relative-link validation, and compact JSON emission. +- Extended the existing `cento parallel-delivery patch-swarm status` command with `--run-dir`, `--output-dir`, `--write-html`, and `--strict-links`. +- Added `--output-dir` compatibility to `cento parallel-delivery patch-swarm e2e` so fixture runs can target `workspace/runs/parallel-delivery/console-fixture/`. +- Added `release-candidate/demo-evidence.md` to the deterministic fixture E2E release-candidate evidence. +- Added a Cento Console route for `/patch-swarm/console?run_dir=...` and `/patch-swarm/runs//console`, plus a `Status console` link in the existing Patch Swarm run detail evidence row. +- Added docs and registry entries for rendering and opening the console hub. +- Added focused tests under `tests/parallel_delivery/test_patch_swarm_console.py`. + +#### What Worked + +- Discovery ran before source edits and found the existing registered `parallel-delivery` and `patch-swarm status` surfaces plus an existing agent-work app Patch Swarm UI. +- The console reads run artifacts directly and does not create a database. +- The `fixture-console-25` run generated 25 candidates, 25 accepted fixture bundles, one rejected unsafe bundle, five simulated worker batches, integration receipts, validation summary/report, and release-candidate evidence. +- `start-here.html` and `console-data.json` were generated under `workspace/runs/parallel-delivery/console-fixture/fixture-console-25/`. +- Link validation passed with all generated HTML links relative, existing, and inside the run directory. +- Browser screenshot validation captured `start-here.png`; the page shows current run, next action, summary cards, task graph, and worker status without obvious clipping or overlap. +- `pytest -q tests/parallel_delivery/test_patch_swarm_console.py` passed with 5 tests. +- Relevant existing Patch Swarm and parallel-delivery tests passed with 52 tests. +- `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, and `cento docs parallel-delivery` passed. + +#### What Did Not Work + +- This host has no `python` executable, so validation used `python3`. +- The original pre-edit discovery E2E command used `--output-dir` before the CLI supported it; it failed and wrote only the discovery log. The new implementation adds that compatibility. +- The current fixture schema uses paths such as `split-plan.json`, `task-graph.json`, `integration/integration-plan.json`, and `release-candidate/release-candidate.json`; the console maps these alongside the prompt's numbered directory shape rather than duplicating all artifacts. + +#### Next Steps + +- Future live Patch Swarm runs can reuse the same console aggregator if they write the known artifact names or add compatible evidence links. +- If the SPA needs richer inline console details later, consume `/api/patch-swarm/runs//console` instead of reimplementing aggregation in JavaScript. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `console`, `static-hub`, `status-json`, `agent-work-app`, `fixture`, `docs`, `tests`, `visual-validation` + +### 2026-05-13T00:52:10Z - Patch Swarm Call 13 Taskstream Handoff + +- `record_id`: 2026-05-13-patch-swarm-call-13-taskstream-handoff +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, taskstream, agent-work, story-manifest, validation-manifest, dry-run-handoff, docs, tests, evidence +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_taskstream.py`, `scripts/parallel_delivery.py`, `scripts/parallel_delivery/taskstream_fixture.py`, `tests/test_parallel_delivery_taskstream.py`, `tests/test_parallel_delivery_agent_work_manifests.py`, `docs/parallel-delivery/patch-swarm-taskstream.md`, `docs/patch-swarm.md`, `README.md`, `Makefile`, `data/tools.json`, `docs/tool-index.md`, `workspace/runs/parallel-delivery/taskstream-fixture/`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m pytest -q tests/test_parallel_delivery_taskstream.py tests/test_parallel_delivery_agent_work_manifests.py`, `make test-taskstream-handoff`, `make taskstream-fixture`, `workspace/runs/parallel-delivery/taskstream-fixture/taskstream-handoff-report.json`, `workspace/runs/parallel-delivery/taskstream-fixture/taskstream-handoff-report.md`, `workspace/runs/parallel-delivery/taskstream-fixture/validation-summary.txt`, `workspace/runs/parallel-delivery/taskstream-fixture/live-refusal.exit-code`, `python3 -m pytest -q tests/test_parallel_delivery_taskstream.py tests/test_parallel_delivery_agent_work_manifests.py tests/test_parallel_delivery_validation_e2e.py tests/test_patch_swarm.py tests/test_parallel_integration_train.py`, `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, `cento docs parallel-delivery`, `git diff --check`, `cento parallel-delivery validate --json`, `cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --run-root workspace/runs/parallel-delivery/e2e-fixture --json`, `make check` +- `checked_prior_records`: `2026-05-13-patch-swarm-call-12-validation-e2e`, `2026-05-13-patch-swarm-call-14-worker-status`, `2026-05-13-patch-swarm-call-15-console-status` +- `corrects_record_id`: none + +#### Trigger + +The operator provided Patch Swarm Call 13 and asked for Taskstream / `cento agent-work` integration while preserving the dirty worktree and avoiding interference with parallel Call 14+ Codex work. + +#### What Changed + +- Added a local-first Patch Swarm to `agent-work` adapter that loads split plans, validates task contracts, rejects unsafe path and secret references, routes implementation tasks to `agent-work`, and keeps evidence-only tasks manifest-only. +- Generated existing `agent-work` compatible `story.json` manifests with `schema_version: 1.0`, `issue.id: 0`, lane metadata, run paths, acceptance contracts, expected outputs, and validation policy. +- Generated existing `cento.validation-manifest.v1` `validation.json` files with Patch Swarm metadata, evidence links, deterministic checks, and record-back transport metadata. +- Added `cento parallel-delivery taskstream emit|preflight|apply`; emit is dry-run by default, preflight calls the existing `cento agent-work preflight` surface, and apply refuses live creation unless `--apply` is present. +- Added a deterministic fixture writer and Makefile targets for taskstream handoff testing and fixture evidence generation. +- Documented the operator flow and registered the new durable commands in `data/tools.json` and the generated tool index. + +#### What Worked + +- Discovery found `agent-work preflight` as the approved story/validation gate and confirmed `cento mcp` only exposes init, doctor, docs, and paths on this host. +- The fixture generated three work packages: two `agent-work` implementation tasks and one manifest-only evidence task. +- Every generated work package includes `story.json`, `validation.json`, `handoff.md`, and `agent-work-command.txt`. +- `agent-work` preflight passed over generated packages without creating live issues. +- The apply path without `--apply` returned a nonzero refusal and wrote refusal evidence. +- Targeted taskstream tests passed with 12 tests, broader Patch Swarm tests passed with 47 tests, and `make check` passed. + +#### What Did Not Work + +- No live MCP story/board/evidence creation route was available from `cento mcp` on this host, so live apply falls back to the existing `cento agent-work create --manifest ...` command path. +- Live `taskstream apply --apply` was not run during validation because tests and fixtures must not create live Taskstream issues. + +#### Next Steps + +- Use generated work packages as the approved handoff input when Patch Swarm tasks need Taskstream visibility. +- Keep live task creation behind explicit `--apply` and review the command preview files before running it. +- If MCP story tools are added later, wire `transport auto` to prefer those tools before `agent-work` command fallback. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `taskstream`, `agent-work`, `story-manifest`, `validation-manifest`, `dry-run`, `evidence`, `tests` + +### 2026-05-13T01:34:00Z - Patch Swarm Call A Gap Closure + +- `record_id`: 2026-05-13-patch-swarm-call-a-gap-closure +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, integration-plan, conflict-triage, safety-hardening, console-dirty-review, evidence, tests +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_validation_e2e.py`, `scripts/parallel_delivery_call_a.py`, `tests/test_parallel_delivery_call_a_gap_closure.py`, `workspace/runs/parallel-delivery/integration-plan-fixture/callA-gap-closure-20260513T013110Z/`, `workspace/runs/parallel-delivery/safety-fixture/callA-gap-closure-20260513T013110Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m pytest -q tests/test_parallel_delivery_call_a_gap_closure.py tests/test_parallel_delivery_validation_e2e.py`, `python3 -m pytest -q tests/test_patch_swarm.py tests/test_parallel_delivery_call_a_gap_closure.py tests/test_parallel_delivery_validation_e2e.py`, `python3 -m pytest -q tests -k "patch_swarm or parallel_delivery or build or workset or factory or safety or integration"`, `cento parallel-delivery validate --json`, `cento parallel-delivery status --json`, `cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json`, `python3 -m py_compile scripts/parallel_delivery_validation_e2e.py scripts/parallel_delivery_call_a.py` +- `checked_prior_records`: `2026-05-13-patch-swarm-call-13-taskstream-handoff`, `2026-05-13-patch-swarm-call-15-console-status` +- `corrects_record_id`: none + +#### Trigger + +The operator compressed the remaining Patch Swarm work into three follow-up calls and asked Codex to implement Call A: close the Call 10 integration-plan/conflict-triage gap, close the Call 16 safety hardening evidence gap, and review dirty Console/status follow-up work without reverting unrelated work. + +#### What Changed + +- Extended the existing deterministic Patch Swarm validation E2E integration planner instead of creating a second integration workflow. +- Added `integration/conflict-report.md`, dependency order, conflict count, safe-apply, needs-rebase, needs-human-review, reject buckets, and rollback metadata to fixture integration evidence. +- Added `scripts/parallel_delivery_call_a.py` to write dedicated Call A integration and safety evidence using existing Patch Swarm, patch bundle, and Taskstream validators. +- Added focused tests for same-path conflict triage, conflict-report generation, safety guard detection, and Console dirty diff classification. +- Reviewed dirty Console files and classified the current dirty hunks as Patch Swarm Console safety/status work, not Industrial/temp work. + +#### What Worked + +- Dedicated integration evidence was written under `workspace/runs/parallel-delivery/integration-plan-fixture/callA-gap-closure-20260513T013110Z/`. +- Dedicated safety evidence was written under `workspace/runs/parallel-delivery/safety-fixture/callA-gap-closure-20260513T013110Z/`. +- The fixture integration plan reported five safe-apply bundles, one rejected unsafe bundle, zero same-path conflicts, and dry-run/no-source-mutation rollback metadata. +- The safety checklist passed for local secret path rejection, absolute/traversal path rejection, unsafe git command detection, direct DB mutation detection, live-dispatch opt-in gates, and Console dirty classification. +- Targeted Call A/Patch Swarm tests passed with 26 tests. +- Focused integration/safety regression passed with 179 tests selected and 118 deselected. +- The 100-candidate fixture E2E passed with 100 accepted fixture bundles, one rejected unsafe bundle, and 20 simulated worker batches. + +#### What Did Not Work + +- No live dispatch, live Taskstream mutation, or patch apply was attempted; this was intentional for Call A. +- The worktree still contains unrelated pre-existing Industrial/temp dirty files. They were preserved and not reset, cleaned, staged, or modified by this call. + +#### Next Steps + +- Run Call B to turn the now-covered integration/safety gates into a broader regression matrix and operator runbook. +- Keep Call C for final evidence indexing, release-candidate status, and adversarial QA after Call B. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `integration-plan`, `conflict-triage`, `safety`, `console`, `dirty-worktree`, `evidence`, `tests` + +### 2026-05-13T16:49:00Z - Patch Swarm Call C Final QA + +- `record_id`: 2026-05-13-patch-swarm-call-c-final-qa +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, final-qa, release-candidate, evidence-index, dirty-worktree, regression, docs, safety +- `status`: implemented +- `artifacts_changed`: `scripts/parallel_delivery_call_c.py`, `workspace/runs/parallel-delivery/final-qa/callC-final-qa-20260513T164733Z/`, `workspace/runs/parallel-delivery/release-candidate/callC-final-qa-20260513T164733Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `python3 -m json.tool data/tools.json`, `python3 -m json.tool data/cento-cli.json`, `cento tools`, `cento docs parallel-delivery`, `cento parallel-delivery validate --json`, `cento parallel-delivery status --json`, `cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json`, `python3 -m pytest -q tests/test_patch_swarm.py`, `python3 -m pytest -q tests -k "patch_swarm or parallel_delivery or build or workset or factory or cli or registry or docs or safety"`, `make check`, `python3 -m py_compile scripts/parallel_delivery_call_c.py` +- `checked_prior_records`: `2026-05-13-patch-swarm-call-a-gap-closure`, `2026-05-13-patch-swarm-call-13-taskstream-handoff`, `2026-05-13-patch-swarm-call-15-console-status` +- `corrects_record_id`: none + +#### Trigger + +The operator asked for Patch Swarm Call C: final integration, adversarial QA, release-candidate gating, evidence indexing, and dirty-work classification without expanding the product scope. + +#### What Changed + +- Added `scripts/parallel_delivery_call_c.py` to convert captured final-gate outputs into an evidence index, owned-path conflict report, final validation summary/report, known limitations, next actions, release candidate JSON, and release notes. +- Generated final QA evidence under `workspace/runs/parallel-delivery/final-qa/callC-final-qa-20260513T164733Z/`. +- Generated release-candidate evidence under `workspace/runs/parallel-delivery/release-candidate/callC-final-qa-20260513T164733Z/`. +- Classified dirty files as Patch Swarm, unrelated, mixed, or evidence-only while preserving unrelated Industrial/temp work. + +#### What Worked + +- Core release gates passed: registry JSON, `cento tools`, `cento docs parallel-delivery`, `validate --json`, `status --json`, 100-candidate fixture E2E, targeted Patch Swarm tests, docs/runbook check, safety scan, and dirty-work preservation. +- Secondary gates passed or were classified: focused final pytest selector passed, `make check` passed, and `make patch-swarm-check` was classified as not present. +- The safety scan found only safe variable-name mentions, fixture fake keys, and disallowed-command test constants; no real secret leak was classified. +- Release candidate status is `pass`. + +#### What Did Not Work + +- No dedicated `make patch-swarm-check` target is present; direct deterministic final gates were used instead. +- Dirty work still contains unrelated or mixed Industrial/temp/Console files. This is recorded as a known limitation for broad packaging, not a Patch Swarm release blocker. + +#### Next Steps + +- Use the final QA and release-candidate evidence packets as the Patch Swarm release gate. +- Review mixed dirty Console/doc files before packaging unrelated Industrial/temp work with Patch Swarm changes. +- Keep live Pro/API, live workers, and Taskstream apply paths behind explicit opt-in flags. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `final-qa`, `release-candidate`, `evidence-index`, `dirty-worktree`, `safety`, `tests` + +### 2026-05-13T17:43:04Z - Patch Swarm Executive One-Pager + +- `record_id`: 2026-05-13-patch-swarm-executive-one-pager +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, one-pager, adoption, evidence-summary, strategy, visual-validation +- `status`: implemented +- `artifacts_changed`: `workspace/runs/parallel-delivery/one-pager/patch-swarm-one-pager-20260513T174304Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/one-pager/patch-swarm-one-pager-20260513T174304Z/index.html`, `workspace/runs/parallel-delivery/one-pager/patch-swarm-one-pager-20260513T174304Z/patch-swarm-one-pager.md`, `workspace/runs/parallel-delivery/one-pager/patch-swarm-one-pager-20260513T174304Z/screenshot.png` +- `checked_prior_records`: `2026-05-13-patch-swarm-call-a-gap-closure`, `2026-05-13-patch-swarm-call-c-final-qa`, `2026-05-13-patch-swarm-call-13-taskstream-handoff`, `2026-05-13-patch-swarm-call-14-worker-pool-status`, `2026-05-13-patch-swarm-call-15-console-status` +- `corrects_record_id`: none + +#### Trigger + +The operator asked for a polished one-pager summarizing the 20-call Patch Swarm buildout: what was done, what the append-only log taught us, what the system can do now, recommendations for next directions, and ideas for how to use the system. + +#### What Changed + +- Created a polished local HTML one-pager with summary metrics, the Patch Swarm delivery flow, a 20-call buildout strip, lessons learned, current capabilities, next-step recommendations, build ideas, and direct evidence links. +- Added a Markdown copy for durable text review. +- Linked the 90-second Call D demo MP4 and added a clean poster so the one-pager opens on Patch Swarm evidence rather than an arbitrary video frame. +- Captured a screenshot of the rendered one-pager for visual validation. + +#### What Worked + +- The one-pager pulls from real evidence: final QA passed, release candidate passed, 100-candidate fixture E2E passed, and demo video evidence exists. +- Visual validation through Playwright confirmed the page renders cleanly at 1440px width with readable text, aligned sections, and no obvious overlap or clipping. +- The page preserves the core product lesson: Patch Swarm scales candidate generation while keeping execution bounded and integration deterministic. + +#### What Did Not Work + +- Firefox headless screenshot with the live browser profile was blocked by an existing Firefox process, so Playwright Chromium was used for deterministic screenshot validation. +- The current worktree still includes unrelated dirty Industrial/temp work. The one-pager was written under `workspace/runs/parallel-delivery/one-pager/` to avoid touching those files. + +#### Next Steps + +- Use the one-pager as the adoption artifact for explaining Patch Swarm to collaborators. +- Convert it into a stable docs page only after current dirty work is packaged. +- Add `make patch-swarm-check` and then use Patch Swarm on one real low-risk Cento feature with live dispatch still disabled. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `one-pager`, `adoption`, `visual-validation`, `strategy`, `evidence` + +### 2026-05-13T19:08:00Z - Patch Swarm 20-Call Closeout + +- `record_id`: 2026-05-13-patch-swarm-20-call-closeout +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, closeout, release-candidate, one-pager, validation, freeze +- `status`: complete +- `artifacts_changed`: `workspace/runs/parallel-delivery/one-pager/patch-swarm-one-pager-20260513T174304Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/one-pager/patch-swarm-one-pager-20260513T174304Z/closeout/closeout-summary.json`, `workspace/runs/parallel-delivery/one-pager/patch-swarm-one-pager-20260513T174304Z/screenshot.png` +- `checked_prior_records`: `2026-05-13-patch-swarm-executive-one-pager`, `2026-05-13-patch-swarm-call-c-final-qa` +- `corrects_record_id`: none + +#### Trigger + +The operator reviewed the executive one-pager, agreed the 20-call buildout is complete as a local-first release candidate, and requested a final closeout pass rather than a new implementation cycle. + +#### What Changed + +- Tightened the one-pager wording from generic release-candidate pass to local fixture release-candidate pass. +- Added explicit evidence paths to the Evidence Trail. +- Added a Phase Closeout section that states what is complete and what is intentionally not claimed yet. +- Relabeled the 20-call row from compressed numbers to `Call N` blocks. +- Captured refreshed visual evidence and a closeout summary. + +#### What Worked + +- The exact closeout checklist passed: `git status --short --branch`, `cento tools`, `cento docs parallel-delivery`, `cento parallel-delivery validate --json`, `cento parallel-delivery status --json`, `cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json`, and `python3 -m pytest -q tests/test_patch_swarm.py`. +- The refreshed one-pager screenshot shows readable cards, clearer call labels, explicit scope boundaries, and evidence paths without obvious clipping or overlap. +- Local link validation passed. + +#### What Did Not Work + +- `make patch-swarm-check` is still not present. This remains a next-step recommendation, not a completed gate. +- The worktree still contains unrelated Industrial/temp dirty work. It remains preserved and must be packaged separately from Patch Swarm. + +#### Next Steps + +- Freeze the 20-call buildout. +- Package Patch Swarm-related changes separately from unrelated dirty work. +- Run one real low-risk pilot with live dispatch still off: non-fixture plan, manually run one or two Codex workers, collect bundles, validate, dry-run integrate, and publish release evidence. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `closeout`, `release-candidate`, `fixture`, `one-pager`, `visual-validation`, `freeze` + +### 2026-05-13T21:59:18Z - Patch Swarm Pro Call Registry + +- `record_id`: 2026-05-13-patch-swarm-pro-call-registry +- `actor`: codex +- `scope`: patch-swarm, pro-loop, pro-call-registry, swarm-runtime, skill-update, evidence +- `status`: implemented +- `artifacts_changed`: `data/patch-swarm-pro-calls.json`, `scripts/patch_swarm_pro_calls.py`, `tests/test_patch_swarm_pro_calls.py`, `/home/alice/.codex/skills/pro-loop/SKILL.md`, `workspace/runs/parallel-delivery/pro-call-registry/pro-call-registry-20260513T215918Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/pro-call-registry/pro-call-registry-20260513T215918Z/validation-summary.json`, `workspace/runs/parallel-delivery/pro-call-registry/pro-call-registry-20260513T215918Z/validation-report.md` +- `checked_prior_records`: `2026-05-13-patch-swarm-20-call-closeout`, `2026-05-13-patch-swarm-call-c-final-qa`, `2026-05-12-pro-loop-fresh-context-skill` +- `corrects_record_id`: none + +#### Trigger + +The operator asked to consume the Swarm Runtime 1.0 Calls 1-30 prompt pack into structured JSON, create placeholders through Call 100, and update the pro-loop skill so future Pro model outputs can be saved back into the registry. + +#### What Changed + +- Added `data/patch-swarm-pro-calls.json` as the canonical repo-backed Patch Swarm Pro call registry. +- Populated Calls 1-30 with the supplied Part 1 GPT Pro prompts and created placeholder records for Calls 31-100. +- Added `scripts/patch_swarm_pro_calls.py` to validate the registry, report stats, show the next call, ingest Pro output into `Pro_output`, and update lifecycle status. +- Added focused registry tests. +- Updated the local `pro-loop` skill so it can read and update the registry workflow instead of requiring an external prompt file for this specific Patch Swarm call queue. + +#### What Worked + +- Registry validation passed with 100 calls, all initially `PENDING`, and all `Pro_output` fields empty. +- The helper reports Call 1 as the next actionable call. +- Targeted tests passed for registry shape, validation, next-call selection, Pro output ingestion, secret-like output blocking, and invalid status transition rejection. + +#### What Did Not Work + +- No live Pro output has been ingested yet; all calls remain pending by design. +- Calls 31-100 are intentionally placeholders until the operator supplies their detailed prompts. + +#### Next Steps + +- When the operator provides Pro output for a call, save it through `python3 scripts/patch_swarm_pro_calls.py ingest-pro-output --call-id N --file PATH`. +- Use `python3 scripts/patch_swarm_pro_calls.py next --json` to pick the next actionable call. +- Move calls to `CODEX_DONE` and `CLOSED` only after Codex implementation and validation evidence exists. + +#### Tags + +`cento-native`, `self-improvement`, `pro-loop`, `patch-swarm`, `parallel-delivery`, `pro-call-registry`, `swarm-runtime`, `evidence`, `dirty-worktree` + +### 2026-05-13T22:02:32Z - Fixture Sickness: Stop Rebuilding The Patch Swarm Control Plane + +- `record_id`: 2026-05-13-fixture-sickness-stop-rebuilding-patch-swarm +- `actor`: codex +- `scope`: patch-swarm, parallel-delivery, fixture-sickness, reuse-gate, governance, planning-correction, self-improvement +- `status`: implemented +- `artifacts_changed`: `docs/fixture-sickness.md`, `workspace/runs/parallel-delivery/fixture-sickness/fixture-sickness-20260513T220232Z/`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/parallel-delivery/fixture-sickness/fixture-sickness-20260513T220232Z/next-phase-correction.md`, `workspace/runs/parallel-delivery/fixture-sickness/fixture-sickness-20260513T220232Z/validation-report.md` +- `checked_prior_records`: `2026-05-13-patch-swarm-20-call-closeout`, `2026-05-13-patch-swarm-pro-call-registry`, `2026-05-13-patch-swarm-call-c-final-qa` +- `corrects_record_id`: `2026-05-13-patch-swarm-pro-call-registry` + +#### Trigger + +The operator identified a severe planning failure after the 20-call Patch Swarm closeout: the next proposed phase drifted into rebuilding contracts, schemas, fixtures, runtime artifacts, queues, ledgers, inboxes, and release packets instead of consuming the already-built Cento surfaces and evidence. + +#### What Changed + +- Named the anti-pattern Fixture Sickness. +- Added `docs/fixture-sickness.md` with the definition, symptoms, causes, harm, existing Cento surfaces to reuse first, and a mandatory Patch Swarm Reuse Gate. +- Wrote next-phase correction evidence that rejects the previous 100-call Part 1 plan in its current form because it restarts from zero. +- Established the new rule: future Patch Swarm planning must pass the Reuse Gate before proposing new calls, fixtures, schemas, manifests, or artifacts. + +#### What Worked + +- The correction keeps the fix at the governance/documentation layer and does not create new product code, registered tools, runtime schemas, fixtures, or durable command surfaces. +- The Reuse Gate gives future agents concrete questions that force reuse of `parallel-delivery`, Factory, Build, Workset, Agent Work, and prior evidence before proposing anything new. +- The next correct move is now explicit: consume the 20-call closeout evidence and run one real low-risk non-fixture pilot through existing Cento surfaces. + +#### What Did Not Work + +- The prior 100-call Swarm Runtime Part 1 plan is rejected in its current form. It is evidence of the anti-pattern, not a queue to execute. +- This correction does not run the real pilot yet. It stops the rebuild loop so the next action can be selected through reuse. + +#### Next Steps + +- Use `docs/fixture-sickness.md` before any new Patch Swarm, Factory, Build, Workset, or Agent Work expansion plan. +- Start from the 20-call closeout evidence and identify the existing command that consumes it next. +- Run one real low-risk non-fixture pilot with live dispatch still off, adding only thin adapters where existing surfaces cannot consume their own outputs. +- Treat evidence as input to the next command, not as an end state. + +#### Tags + +`cento-native`, `self-improvement`, `patch-swarm`, `parallel-delivery`, `fixture-sickness`, `reuse-gate`, `governance`, `planning-correction`, `evidence` + +### 2026-05-14T00:06:25Z - Temp Clipboard Routing And Self-Updating Skill Guard + +- `record_id`: 2026-05-14-temp-clipboard-routing-self-updating-skill-guard +- `actor`: codex +- `scope`: cento-temp, routing, skills, skill-creator, proreq-prompt-bridge, e2e +- `status`: implemented +- `artifacts_changed`: `data/tools.json`, `data/cento-cli.json`, `docs/temp-commands.md`, `docs/cento-cli.md`, `docs/tool-index.md`, `docs/nav.html`, `docs/parallel-delivery/patch-swarm-proreq-prompts.md`, `scripts/parallel_delivery_prompts.py`, `scripts/write_cento_secret_env.py`, `tests/test_cento_temp_contract.py`, `tests/test_parallel_delivery_proreq_prompts.py`, `skills/codex/cento-native/SKILL.md`, `skills/codex/cento-native/references/routing.md`, `skills/claude-code/cento-native.md`, `/home/alice/.codex/skills/cento-native/SKILL.md`, `/home/alice/.codex/skills/cento-native/references/routing.md`, `/home/alice/.codex/skills/cento-temp-pbcopy/SKILL.md`, `/home/alice/.codex/skills/cento-temp-pbcopy/agents/openai.yaml`, `/home/alice/.codex/skills/.system/skill-creator/SKILL.md`, `docs/ai-self-improvement-log.md` +- `evidence`: `workspace/runs/temp/cento-temp-routing-e2e-20260514T000554Z/validation-summary.json`, `workspace/runs/temp/cento-temp-routing-e2e-20260514T000554Z/pytest-focused.log`, `workspace/runs/temp/cento-temp-routing-e2e-20260514T000554Z/cento-docs-temp.txt` +- `checked_prior_records`: `2026-05-13-fixture-sickness-stop-rebuilding-patch-swarm`, `2026-05-13-patch-swarm-pro-call-registry` +- `corrects_record_id`: none + +#### Trigger + +The operator reported that `cento temp run` had become slow and overbuilt, then asked to update intent routing and skill guidance so the temp path stays a one-command pbcopy wrapper. + +#### What Changed + +- Updated the registered `temp` tool to expose only `cento temp run`. +- Added `temp-clipboard-routing` to the root CLI routing table. +- Replaced temp docs and generated tool/nav surfaces so they no longer advertise IDs, flags, secret prompts, or generated temp command registries. +- Updated Codex and Claude Cento routing skills to route temp clipboard requests only to `cento temp run`. +- Updated `skill-creator` with a corrective-skill self-maintenance rule. +- Added a self-update contract and UI metadata to the local `cento-temp-pbcopy` skill. +- Changed the Patch Swarm ProReq prompt bridge so it no longer writes or claims support for generated temp-command JSON. + +#### What Worked + +- `./scripts/cento.sh temp run` copied the fixed Markdown through a fake `pbcopy` in the e2e check. +- `show` and extra-argument variants are rejected before copying with `Usage: cento temp run`. +- Focused tests passed: `python3 -m pytest -q tests/test_cento_temp_contract.py tests/test_parallel_delivery_proreq_prompts.py`. +- Skill validation passed for `cento-temp-pbcopy`, `cento-native`, and `skill-creator`. + +#### What Did Not Work + +- The first evidence script aborted when `rg` correctly returned no matches under `set -e`; the existing logs were reused and a validation summary was written after confirming the empty stale-route report. + +#### Next Steps + +- Keep `cento temp` as a fixed wrapper. For other one-off shell work, use `cluster`, `bridge`, or `batch-exec`. +- If a future prompt needs to become the copied file, edit only `COPY_FILE` in `scripts/cento_temp.sh`. + +#### Tags + +`cento-native`, `self-improvement`, `routing`, `skills`, `skill-creator`, `temp`, `pbcopy`, `clipboard`, `e2e` diff --git a/docs/ai-self-improvement-nightly.md b/docs/ai-self-improvement-nightly.md new file mode 100644 index 0000000..dd557a1 --- /dev/null +++ b/docs/ai-self-improvement-nightly.md @@ -0,0 +1,68 @@ +# AI Self-Improvement Nightly + +`cento parallel-delivery self-improve` runs the gated nightly Cento self-improvement loop. + +The loop performs four ordered Hard ProReq planning passes: + +1. Scope and guardrails. +2. Architecture. +3. Integration and workset strategy. +4. Validation, promotion recommendation, and next-cycle request. + +Each pass consumes the prior pass results, failures, guidance, and next-step request. A degraded pass may feed the next pass only as failure evidence. + +## Commands + +```bash +cento parallel-delivery self-improve run --json +cento parallel-delivery self-improve e2e --candidate-target 30 --max-parallel-agents 3 --budget-cap-usd 1 --max-budget-usd 1 --apply --validate-each --auto-merge-gate --json +cento parallel-delivery self-improve validate --json +cento parallel-delivery self-improve status --json +cento parallel-delivery self-improve install-cron --time 02:30 +cento parallel-delivery self-improve uninstall-cron +``` + +## Artifacts + +Nightly artifacts are written under: + +```text +workspace/runs/ai-self-improvement-nightly// +workspace/runs/ai-self-improvement-nightly/latest/ +``` + +The stable artifact set is: + +- `nightly_cycle_manifest.json` +- `pass_01_child_run_summary.json` through `pass_04_child_run_summary.json` +- `validation_gates.json` +- `loop_metrics.json` +- `promotion_recommendation.json` +- `evidence_handoff.json` +- `next_cycle_request.json` + +If `latest/next_cycle_request.json` is missing, the seed comes from the newest previous continuous ProReq handoff under `workspace/runs/ai-cento-native-continuous-proreq/*/validation_handoff.json`. + +## Gates + +Blocking gates fail the cycle when Pro artifacts are missing or blank, a child pass is degraded, a workset does not pass its declared path policy, or `latest/` is stale. + +Image generation is nonblocking. A `gpt-image-2` 403 is recorded as evidence and does not fail backend planning. + +Generated new-file worksets require an explicit create-file policy: + +```bash +cento workset check WORKSET --runtime api-openai +``` + +Plain `cento workset check WORKSET` remains strict and rejects missing write paths. + +## Compute Routing + +The nightly loop records the Cento compute policy in each manifest and next-cycle request. + +When Codex/Claude weekly utilization is above 30% and capacity remains usable, follow-up work should prefer Codex/Claude agent lanes over metered OpenAI API for about 70-80% of eligible non-API-only cases. OpenAI API remains reserved for structured Responses API, image generation, ProReq planning, and other API-only behavior. + +The loop never executes implementation worksets automatically. It plans, validates, summarizes, recommends promotion, writes the next request, and stops. + +The separate e2e autopilot command consumes the latest `next_cycle_request.json` and continues through Patch Swarm, Factory `validate-fanout`, bounded Safe Integrator apply, and `factory merge --auto-merge-main --dry-run`. See `docs/ai-self-improvement-autopilot.md`. diff --git a/docs/builder-agent-guidelines.md b/docs/builder-agent-guidelines.md new file mode 100644 index 0000000..bded39b --- /dev/null +++ b/docs/builder-agent-guidelines.md @@ -0,0 +1,76 @@ +# Builder Agent Guidelines + +Builders work inside a Cento build manifest. Owned write paths are exclusive. Read paths are non-exclusive. Protected paths are never editable. + +## Builder May + +- inspect read paths +- edit only owned write paths +- create a patch artifact +- run allowed validation commands +- summarize assumptions +- report blockers +- report files touched + +## Builder Must Output + +Each Builder writes these files under its manifest artifact directory: + +```text +patch.diff +patch_bundle.json +worker_artifact.json +handoff.md +``` + +`worker_artifact.json` uses `cento.worker_artifact.v1` and records worker id, role, status, manifest id, base ref, owned paths, touched paths, patch path, assumptions, validation, blockers, and risks. + +`patch_bundle.json` uses `cento.patch_bundle.v1` and records touched paths, owned paths, unowned paths, protected paths touched, summary, base ref, and whether integration is required. + +`handoff.md` stays short: + +```markdown +# Builder Handoff + +## Changed +- Updated the owned page. + +## Touched files +- tests/fixtures/cento_build/app_page.html + +## Assumptions +- Used static fixture content. + +## Validation +- Diff check: passed + +## Risks +- No live data source is connected. +``` + +## Builder Must Never + +- edit unowned paths +- stage unrelated files +- commit unless explicitly allowed +- push unless explicitly allowed +- modify protected files +- change lockfiles unless explicitly owned and allowed +- touch `.env` or credentials +- silently expand scope +- overwrite dirty owned files +- hide validation failures + +## Dirty Repo Rules + +Dirty unrelated files are preserved and ignored. Dirty owned files block or warn according to the manifest policy. Staged unrelated files must not be touched. + +## Patch Requirements + +Generate patches against the current base: + +```bash +git diff -- > patch.diff +``` + +The patch must not include unowned paths, protected paths, binary blobs, or lockfiles unless the manifest explicitly owns and allows them. diff --git a/docs/cento-build.md b/docs/cento-build.md new file mode 100644 index 0000000..bd5a0c6 --- /dev/null +++ b/docs/cento-build.md @@ -0,0 +1,274 @@ +# Cento Build + +`cento build` is the local, deterministic build-package primitive for future parallel workers. It is a safe work-package contract and patch acceptance layer. It does not launch cloud workers, call model APIs, create PRs, or schedule worker pools. + +The v1.2 local-builder flow is: + +```text +operator task -> manifest -> owned paths -> builder prompt -> local worker -> patch bundle -> integration dry-run -> apply -> validation -> evidence +``` + +## Commands + +Create a manifest and Builder prompt: + +```bash +cento build init \ + --task "Fixture docs page patch" \ + --mode fast \ + --write tests/fixtures/cento_build/app_page.html \ + --route /fixture +``` + +This writes: + +```text +.cento/builds//manifest.json +.cento/builds//builder.prompt.md +``` + +Validate a manifest: + +```bash +cento build check .cento/builds//manifest.json +``` + +Print or rewrite the Builder prompt: + +```bash +cento build prompt .cento/builds//manifest.json +cento build prompt .cento/builds//manifest.json --write +``` + +Dry-run integrate a worker patch: + +```bash +cento build bundle synthesize \ + --manifest .cento/builds//manifest.json \ + --patch .cento/builds//workers/builder_1/patch.diff + +cento build integrate .cento/builds//manifest.json \ + --bundle .cento/builds//integration/patch_bundle.json \ + --dry-run +``` + +Check a worker artifact explicitly: + +```bash +cento build artifact check .cento/builds//workers/builder_1/worker_artifact.json +``` + +Run one local worker and collect its patch bundle: + +```bash +cento build worker run .cento/builds//manifest.json \ + --worker builder_1 \ + --runtime fixture \ + --fixture-case valid \ + --worktree \ + --timeout 180 +``` + +Fixture cases are `valid`, `unowned`, `protected`, `delete`, `lockfile`, and `binary`. `valid` writes only the first owned path; unsafe cases must be rejected by the build guard. + +Runtime profiles are the preferred command-worker path: + +```bash +cento runtime check codex-fast + +cento build worker run .cento/builds//manifest.json \ + --worker builder_1 \ + --runtime-profile codex-fast \ + --worktree +``` + +Profiles live in `.cento/runtimes.yaml`. Command profiles use argv arrays, scrubbed environment allowlists, explicit timeouts, isolated worktrees, and post-run path guards. Missing command executables are warnings from `cento runtime check` unless `--require-executable` is passed. + +Raw command strings are available only as an explicit local escape hatch: + +```bash +cento build worker run .cento/builds//manifest.json \ + --worker builder_1 \ + --runtime command \ + --command "codex exec --prompt-file {prompt}" \ + --allow-unsafe-command \ + --worktree \ + --timeout 180 +``` + +Command placeholders are `{manifest}`, `{prompt}`, `{build_dir}`, `{worker_dir}`, `{worktree}`, `{worker}`, and `{artifact_dir}`. + +This writes: + +```text +.cento/builds//workers/builder_1/worker_artifact.json +.cento/builds//workers/builder_1/patch_bundle.json +.cento/builds//workers/builder_1/patch.diff +.cento/builds//workers/builder_1/handoff.md +``` + +Rejected workers still write `worker_artifact.json`, `patch.diff` when available, and rejection details. Safe output uses worker artifact status `completed`; unsafe output uses `rejected`; runtime failure uses `failed`. + +Run the integration dry-run in an isolated clean worktree: + +```bash +cento build integrate .cento/builds//manifest.json \ + --bundle .cento/builds//workers/builder_1/patch_bundle.json \ + --worktree \ + --dry-run +``` + +Apply a previously accepted integration receipt to the operator worktree: + +```bash +cento build apply .cento/builds//manifest.json \ + --bundle .cento/builds//workers/builder_1/patch_bundle.json \ + --from-receipt .cento/builds//integration_receipt.json +``` + +Print the latest receipt: + +```bash +cento build receipt .cento/builds/ +``` + +## Manifest Contract + +The manifest is JSON first. It declares: + +- `schema_version`: `cento.build.v1` +- `id`: stable build id +- `task`: title and description +- `mode`: execution mode from `.cento/modes.yaml` +- `scope.routes`: affected routes +- `scope.read_paths`: non-exclusive read paths +- `scope.write_paths`: exclusive owned write paths +- `scope.protected_paths`: paths that cannot be edited +- `policies`: ask, dirty repo, commit, push, and change policies +- `validation`: validation tier and commands +- `workers`: local worker artifact directories + +`cento build init --mode fast` copies the mode policy into the manifest. `standard` and `thorough` produce different policy metadata. + +## Integration Rules + +`cento build integrate` accepts a patch only when: + +- the manifest is valid +- a patch bundle is supplied, unless `--dev-raw-patch` is explicitly used for fixture/dev work +- all touched paths are owned +- no protected paths are touched +- no binary, traversal, absolute-path, symlink, submodule, undeclared delete, or unowned-rename patch is present +- lockfile changes are explicitly owned +- dirty owned paths are absent unless `--allow-dirty-owned` is passed +- `git apply --check` passes +- validation commands pass + +It rejects patches that touch unowned files, protected files, lockfiles not explicitly owned, mismatched manifest or worker artifacts, or patches that do not apply cleanly. Every integration attempt writes: + +```text +.cento/builds//integration_receipt.json +.cento/builds//validation_receipt.json +``` + +`cento build apply` applies only after the integration receipt is accepted. It rejects when the manifest id, bundle id/path, base ref, dirty-owned state, bundle contract, or patch apply check fails. A successful apply writes: + +```text +.cento/builds//apply_receipt.json +.cento/builds//taskstream_evidence.json +``` + +The fixture checks are: + +```bash +cento build check tests/fixtures/cento_build/manifest.valid.json +cento build artifact check tests/fixtures/cento_build/worker_artifact.valid.json +cento build artifact check tests/fixtures/cento_build/worker_artifact.unowned.json +cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff +cento build integrate tests/fixtures/cento_build/manifest.valid.json --bundle .cento/builds/build_fixture_docs_page_001/integration/patch_bundle.json --dry-run +cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.unowned.diff +cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.protected.diff +cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.traversal.diff +cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.binary.diff +cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180 +cento build apply .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --from-receipt .cento/builds//integration_receipt.json +``` + +The valid bundle is accepted when the owned path is clean or `--allow-dirty-owned` is explicit. The raw patch integration path rejects by default. The unowned artifact, unowned patch, protected patch, traversal patch, and binary patch are rejected. + +## Run Fast + +`cento run fast` now creates an implicit build package for owned-path tasks: + +```bash +cento run fast \ + --task "Patch docs page title" \ + --write apps/watch/KanjiADay/Preview/index.html \ + --route /docs/apps/kanji-a-day +``` + +With `--local-builder fixture --fixture-case valid --apply`, `cento run fast` runs one isolated local builder, dry-runs the collected patch bundle, applies it if accepted, runs smoke validation, and writes evidence: + +```bash +cento run fast \ + --task "Patch docs page title" \ + --write apps/watch/KanjiADay/Preview/index.html \ + --route /docs/apps/kanji-a-day \ + --local-builder fixture \ + --fixture-case valid \ + --apply \ + --validation smoke \ + --commit none +``` + +The same fast path can use a real local runtime profile: + +```bash +cento run fast \ + --task "Patch docs page title" \ + --write apps/watch/KanjiADay/Preview/index.html \ + --route /docs/apps/kanji-a-day \ + --runtime-profile codex-fast \ + --apply \ + --validation smoke \ + --commit none +``` + +This writes: + +```text +.cento/builds//manifest.json +.cento/builds//builder.prompt.md +.cento/builds//workers/builder_1/worker_artifact.json +.cento/builds//workers/builder_1/patch_bundle.json +.cento/builds//integration_receipt.json +.cento/builds//apply_receipt.json +.cento/builds//validation_receipt.json +.cento/builds//taskstream_evidence.json +``` + +Without `--local-builder`, the integration receipt remains `pending` and records that no worker patch was collected. + +## Factory And Taskstream + +`cento build` is the concrete local slice that Factory can call before real worker scheduling exists. It covers materialize, local lease shape, collect, validate, and dry-run integrate for one manifest-owned patch package. + +Each generated build writes `.cento/builds//events.ndjson`. Current event names are: + +```text +build_manifest_created +builder_prompt_created +worker_started +worker_artifact_written +worker_artifact_received +validation_receipt_written +integration_dry_run_passed +integration_dry_run_rejected +integration_receipt_pending +patch_applied +patch_apply_rejected +taskstream_evidence_attached +build_completed +``` + +Taskstream should treat a build as a work unit only when the integration and validation receipts provide evidence. `cento workset` is the thin local orchestrator above this one-worker contract: it runs exclusive-path tasks in parallel worktrees, then feeds each patch back through `cento build` sequential integration and apply. Factory remains responsible for future materialization and release candidate flow. diff --git a/docs/cento-cli.md b/docs/cento-cli.md index b865190..742d1ad 100644 --- a/docs/cento-cli.md +++ b/docs/cento-cli.md @@ -23,6 +23,20 @@ Use these entrypoints: - `cento tmux status` Show tmux badge integration state. +Checklist included in `cento docs`: + +- Discover: start with `cento docs`, `cento tools`, and a repo search before + adding a new command or workflow. +- Task: for Cento feature, automation, MCP, cluster, mobile, UI, or command + behavior changes, create an `agent-work` story manifest and task before + implementation. +- Align: keep `data/cento-cli.json`, affected docs in `docs/`, and any + generated indexes aligned with the actual command surface. +- Validate: run the narrow deterministic checks for the files changed, + including JSON validation for docs sources. +- Evidence: leave validation evidence in the relevant + `workspace/runs/agent-work//` bundle and update Taskstream status. + Built-ins currently documented in JSON: - `help` @@ -35,6 +49,37 @@ Built-ins currently documented in JSON: - `install` - `tmux` - `run` +- `build` +- `runtime` +- `workset` +- `factory` +- `storage` + +Local build loop: + +- `cento run fast --task ... --write PATH --local-builder fixture --fixture-case valid --apply` + creates a manifest-owned build package, runs one isolated fixture/local builder, + dry-runs the patch bundle, applies the accepted patch, validates, and writes + Taskstream evidence. +- `cento build worker run MANIFEST --worker builder_1 --runtime fixture --fixture-case valid --worktree` + collects `worker_artifact.json`, `patch_bundle.json`, `patch.diff`, and + `handoff.md`. +- `cento runtime check codex-fast` + validates the hardened local command runtime profile. +- `cento build worker run MANIFEST --runtime-profile codex-fast --worktree` + runs a local command adapter through an argv-array profile; raw shell commands + require `--allow-unsafe-command`. +- `cento build apply MANIFEST --bundle PATCH_BUNDLE --from-receipt RECEIPT` + applies only from an accepted integration receipt. +- `cento workset run WORKSET --max-workers 3 --runtime-profile codex-fast --apply sequential` + runs exclusive-path tasks in parallel worktrees, then integrates and applies + accepted patches one at a time. +- `cento workset execute WORKSET --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply` + runs ready tasks in parallel, requires structured API artifacts, materializes + them locally into patch bundles, and keeps integration/apply sequential. +- `cento workset materialize-artifact ARTIFACT` + converts one `cento.api_worker_artifact.v1` JSON artifact into a local build + patch bundle without letting the API worker mutate repository files. Routing rules: @@ -42,6 +87,12 @@ Routing rules: Dispatch to a registered tool id from `data/tools.json`. - `cento ALIAS [args...]` Dispatch to a configured alias from `~/.config/cento/aliases.sh`. +- `cento walk-autopilot routing run --json` + Collect counts-only routing and Cento-native observability, write a decision + report, and hand off bounded follow-up work without implementing from cron. +- `cento temp run` + Copy the fixed Markdown reference configured in `scripts/cento_temp.sh` + through `pbcopy`. This route accepts no IDs, flags, or generated temp entries. Terminal integration: diff --git a/docs/cento-web-app.md b/docs/cento-web-app.md index 23dd8a2..c96840b 100644 --- a/docs/cento-web-app.md +++ b/docs/cento-web-app.md @@ -6,6 +6,7 @@ The Cento web app is the operator console for the whole Cento system. Taskstream - `Taskstream`: issues, review queue, dispatch, validation evidence, and agent-work lifecycle. - `Factory`: manifest-driven runs, delivery status, queue pressure, dispatch plans, integration gates, and AI-call totals. +- `Patch Swarm`: local repo selection, task-to-candidate patch generation, candidate review, approval, and supervised worktree apply. - `Cluster`: node health, bridge mesh, Agent Processes, manual agents, worker pools, and runtime usage. - `Consulting`: CRM, career intake, funnel, and client deliverables. - `Docs`: operating guides, tasking contracts, validation lanes, runbooks, and generated tool references. @@ -22,7 +23,7 @@ The `/docs` route is a first-class documentation module with: - documentation sidebar with a Cento general group expanded by default - product-area groups folded by default for Taskstream, Cluster, Consulting, and References - hero copy for Cento Documentation -- six Explore by area cards +- Explore by area cards for Cento systems and app documentation such as Kanji a Day - Recent updates list - support callout - right-side On this page rail on desktop diff --git a/docs/cento-workset.md b/docs/cento-workset.md new file mode 100644 index 0000000..e61b1ef --- /dev/null +++ b/docs/cento-workset.md @@ -0,0 +1,177 @@ +# Cento Workset + +`cento workset` is the first local parallelization layer above `cento build`. +It runs multiple exclusive-path build tasks, collects one patch bundle per task, +then integrates and applies accepted patches through one sequential lane. +`cento workset execute` adds structured API artifact workers while keeping repo +mutation inside the local materializer and build integration layer. + +It does not run cloud workers, use OpenAI Batch API, create PRs, split +screenshots into epics, or attempt smart merging. + +## Contract + +Parallelization v1 constraints: + +- each task owns exclusive `write_paths` +- no shared files +- no overlapping paths +- no glob write paths +- dependency gates are simple `depends_on` arrays +- a task dispatches only after dependencies are completed and applied +- integration and apply are always sequential +- conflicts block only the affected branch; independent tasks continue +- API workers write structured artifacts only; local materialization writes files +- `.cento/api_workers.yaml` defines the configured hard budget maximum; CLI + `--max-budget-usd` cannot raise it +- budget caps reserve at least `minimum_cost_usd_estimate_per_request` for each + API worker before dispatch +- API workers enforce `max_input_chars` before the OpenAI request and + `max_output_tokens` on the Responses API request +- budget caps stop new API dispatch before the hard max is exceeded, and a + real usage estimate above the hard max blocks integration for that task + +Shared-file edits must be represented as a separate serialized integrator task. + +## API Worker Config + +`.cento/api_workers.yaml` controls API-worker safety defaults: + +```yaml +openai: + budget_usd_default: 3 + budget_usd_max: 5 + max_parallel_requests: 6 + timeout_seconds: 45 + retry_attempts: 1 + cost_usd_estimate_per_request: 0.10 + minimum_cost_usd_estimate_per_request: 0.10 + max_input_chars: 20000 + max_output_tokens: 2000 +``` + +Profiles may lower or specialize per-request limits, but the configured +`budget_usd_max` remains the hard ceiling for `cento workset execute`. + +## Workset Manifest + +```json +{ + "schema_version": "cento.workset.v1", + "id": "workset_kanji_docs", + "mode": "fast", + "max_parallel": 3, + "tasks": [ + { + "id": "hero", + "task": "Update hero section", + "write_paths": ["apps/docs/kanji/Hero.tsx"], + "depends_on": [] + }, + { + "id": "status", + "task": "Update status cards", + "write_paths": ["apps/docs/kanji/StatusCards.tsx"], + "depends_on": [] + }, + { + "id": "layout", + "task": "Wire page layout after sections are ready", + "write_paths": ["apps/docs/kanji/Page.tsx"], + "depends_on": ["hero", "status"] + } + ] +} +``` + +## Commands + +Validate exclusive paths and dependencies: + +```bash +cento workset check tests/fixtures/cento_workset/workset.valid.json +cento workset check tests/fixtures/cento_workset/workset.overlap.json +``` + +Run two local fixture workers, then integrate and apply sequentially: + +```bash +cento workset run tests/fixtures/cento_workset/workset.valid.json \ + --max-workers 2 \ + --runtime-profile fixture-valid \ + --apply sequential \ + --validation smoke +``` + +Run the same shape with a real local command profile: + +```bash +cento workset run workset.json \ + --max-workers 3 \ + --runtime-profile codex-fast \ + --apply sequential \ + --validation smoke +``` + +Execute with the v1 command shape: + +```bash +cento workset execute .cento/worksets/docs_page.json \ + --max-parallel 6 \ + --runtime api-openai \ + --budget-usd 3 \ + --max-budget-usd 5 \ + --integrate sequential \ + --apply \ + --validation smoke +``` + +Fixture and local command execution use the same dispatcher: + +```bash +cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json \ + --max-parallel 3 \ + --runtime fixture \ + --integrate sequential \ + --validation smoke + +cento workset execute workset.json \ + --max-parallel 3 \ + --runtime local-command \ + --runtime-profile codex-fast \ + --integrate sequential \ + --validation smoke +``` + +Materialize one API artifact into a local patch bundle: + +```bash +cento workset materialize-artifact .cento/worksets//workers//artifact.json +``` + +## Outputs + +Each run writes: + +```text +.cento/worksets//workset.json +.cento/worksets//leases.json +.cento/worksets//workset_receipt.json +.cento/worksets//workset_evidence.json +.cento/worksets//events.ndjson +.cento/worksets//workers//request.json +.cento/worksets//workers//response.json +.cento/worksets//workers//artifact.json +.cento/worksets//workers//cost_receipt.json +.cento/worksets//workers//worker_receipt.json +``` + +Each task still writes a normal build package under: + +```text +.cento/builds/workset__/ +``` + +Those build packages contain the normal manifest, builder prompt, +worker artifact, patch bundle, integration receipt, apply receipt, validation +receipt, taskstream evidence, and events. diff --git a/docs/claude-code-chores.md b/docs/claude-code-chores.md new file mode 100644 index 0000000..6660f8d --- /dev/null +++ b/docs/claude-code-chores.md @@ -0,0 +1,56 @@ +# Claude Code Chores + +Claude Code chores are a controlled Cento maintenance loop for using otherwise idle Claude Code subscription capacity on small repo-local work. The loop is native Cento plumbing: it discovers chores, creates Taskstream issues with story and validation manifests, and launches workers through `agent-work` and `agent-pool-kick`. + +## Command Surface + +- `cento claude-chores plan --scope broad-repo --json` +- `cento claude-chores run --scope broad-repo --chore-limit 2 --max-launch 2 --runtime claude-code --model claude-sonnet-4-6 --json` +- `cento claude-chores run --scope broad-repo --chore-limit 2 --max-launch 2 --dry-run --json` +- `cento claude-chores status --json` +- `cento claude-chores install-cron --interval-minutes 30 --json` +- `cento claude-chores uninstall-cron --json` + +## Default Policy + +- Cadence: every 30 minutes when the managed cron block is installed. +- Per tick: create at most 2 new chores and launch at most 2 new Claude Code jobs. +- Active targets: `builder=2`, `small=1`, `validator=1`, `coordinator=0`. +- Runtime/model: `claude-code` with `claude-sonnet-4-6`. +- Package filter: worker launch is constrained to the `claude-chores` package, so cron does not dispatch unrelated queued work. +- Spend policy: this loop uses agent subscription capacity and does not route chores through metered OpenAI API workers. + +When Codex/Claude weekly utilization is above 30%, eligible non-API-only work should prefer agent lanes in roughly 70-80% of cases. Claude chores implement that preference for maintenance work by forcing Claude Code, keeping API spend at zero, and using small validation-focused tasks. + +## Chore Sources + +The broad repo scanner looks for deterministic maintenance work: + +- missing registered tool entrypoints, such as a registry entry whose script path is absent; +- docs/CLI drift, such as stale references to removed commands; +- TODO/FIXME hotspots in `scripts/`, `docs/`, `tests/`, and `data/`; +- blocked Taskstream issues that need manifest repair, clearer owned paths, or a closure recommendation. + +Each candidate receives a stable fingerprint. Open `claude-chores` issues with the same `[chore:]` prefix block duplicate creation on later cron ticks. + +## Artifacts + +Each `plan` or `run` writes: + +- `workspace/runs/claude-chores//candidate_chores.json` +- `workspace/runs/claude-chores//created_issues.json` +- `workspace/runs/claude-chores//dispatch_summary.json` +- `workspace/runs/claude-chores//claude-code-chores.md` +- `workspace/runs/claude-chores//status.json` +- `workspace/runs/claude-chores/latest/status.json` + +The run Markdown includes a process benefit scan from native Cento state instead of messaging unrelated untracked Codex processes. That keeps the loop auditable and avoids interrupting live interactive sessions. + +## Cron + +`cento claude-chores install-cron --interval-minutes 30 --json` installs a guarded block between: + +- `# >>> cento claude-chores >>>` +- `# <<< cento claude-chores <<<` + +The cron command uses `flock` and logs to `~/.local/state/cento/claude-chores.log`. Existing cron blocks are preserved. Use `--crontab-file` for tests and dry-run validation so the real crontab is not modified. diff --git a/docs/client-intake-hub.md b/docs/client-intake-hub.md new file mode 100644 index 0000000..d102630 --- /dev/null +++ b/docs/client-intake-hub.md @@ -0,0 +1,35 @@ +# Client Intake Hub + +Client Intake Hub is the first real-file Tool Foundry bundle for the career consulting workflow. It is still fixture-only: it proves Cento can materialize a repo-ready tool surface without using real resumes, LinkedIn exports, private notes, or client PII. + +## Current State + +- `status`: materialized MVP +- `target_root`: `templates/foundry/client-intake-hub` +- `domain`: `career-consulting` +- `privacy`: fixture data only, local-first, no public upload + +## Materialized Files + +- `templates/foundry/client-intake-hub/client-intake-hub.html` +- `templates/foundry/client-intake-hub/client-profile.schema.json` +- `templates/foundry/client-intake-hub/command-api.json` +- `templates/foundry/client-intake-hub/storage-leak-policy.json` +- `templates/foundry/client-intake-hub/validation-plan.json` +- `templates/foundry/client-intake-hub/README.md` + +## Preview + +Run the CRM server and open the Studio view: + +```bash +cento crm serve +``` + +The CRM exposes Foundry tool metadata at `/api/foundry/tools` and serves the generated preview from `/foundry/client-intake-hub/client-intake-hub.html`. + +## Safety + +- The bundle uses only the built-in Ada Lovelace fixture profile. +- Existing materialized files are not overwritten by Foundry unless their content is identical. +- OCI upload is not part of this MVP; storage remains local unless a later explicit storage promotion is approved. diff --git a/docs/compute-policy.md b/docs/compute-policy.md new file mode 100644 index 0000000..76ac7e7 --- /dev/null +++ b/docs/compute-policy.md @@ -0,0 +1,68 @@ +# Compute Policy + +`cento compute-policy` controls the preferred mix of Codex, Claude Code, and metered OpenAI API usage. + +Use it when you have agent subscription or limit available and want Cento to avoid API spend for work that can run through an agent. + +## Quick Start + +Prefer Codex for most agent dispatch and disable metered API by default: + +```bash +cento compute-policy preset codex-first --json +``` + +Prefer Codex/Claude agents for roughly 70-80% of eligible work while still allowing explicit API-only lanes: + +```bash +cento compute-policy preset agent-preferred --json +``` + +Set exact shares: + +```bash +cento compute-policy set --codex 85 --claude 15 --openai-api 0 --json +``` + +Inspect the active policy: + +```bash +cento compute-policy show --json +cento agent-work runtimes --sample 100 --json +``` + +## What It Changes + +- Writes `.cento/compute-policy.json`. +- Synchronizes Codex and Claude shares into `data/agent-runtimes.json`. +- Leaves explicit `api-openai` commands explicit. If a command asks for `--runtime api-openai`, that remains an intentional API path. +- Records OpenAI API share as policy metadata so autopilot logs can analyze intended API usage separately from agent runtime usage. + +## Presets + +- `codex-first`: Codex 85, Claude 15, OpenAI API 0. +- `agent-preferred`: Codex 55, Claude 20, OpenAI API 25. +- `balanced`: Codex 50, Claude 30, OpenAI API 20. +- `claude-first`: Codex 20, Claude 80, OpenAI API 0. +- `api-minimal`: Codex 70, Claude 30, OpenAI API 0. +- `api-assisted`: Codex 50, Claude 25, OpenAI API 25. + +## Agent-Preferred Threshold + +When Codex/Claude weekly utilization is above 30% and capacity remains usable, Cento should prefer Codex/Claude agent lanes over metered OpenAI API for about 70-80% of eligible non-API-only work. + +OpenAI API remains the explicit route for structured Responses API work, image generation, ProReq planning, and other API-only behavior. The policy is recorded in `.cento/compute-policy.json` as `agent_preference_policy` so nightly planning and follow-up handoffs can carry the same spending rule. + +## Agent Pool Behavior + +`cento agent-pool-kick` now defaults to Agent Work `auto` runtime routing instead of forcing Claude Code. + +That means: + +- non-validator agent dispatch follows the weighted Codex/Claude registry, +- strong GPT validator dispatch still forces Codex when the model override is a GPT model, +- explicit `CENTO_AGENT_RUNTIME=claude-code` or `CENTO_AGENT_RUNTIME=codex` still overrides policy. + +## Guardrails + +OpenAI API share does not silently block or rewrite explicit API commands. It is a policy signal: pipelines should prefer agents where possible and reserve API calls for structured Responses, image generation, ProReq lanes, or other API-only behavior. diff --git a/docs/demo-evidence.md b/docs/demo-evidence.md new file mode 100644 index 0000000..bee7e7a --- /dev/null +++ b/docs/demo-evidence.md @@ -0,0 +1,116 @@ +# Demo Evidence Recorder + +`cento demo-evidence` records short desktop videos for Factory, Codex worker, and Validator evidence bundles. + +Use it when a worker has a visible product flow to prove and a screenshot is too weak. The tool enforces a 10-30 second window, writes an MP4, and records a machine-readable receipt with the command, recorder backend, requested and measured duration, hash, and output paths. + +## Quick Start + +Record a 15 second local demo: + +```bash +cento demo-evidence record --title "Settings panel save flow" --duration 15 +``` + +Record evidence directly into a Factory task bundle: + +```bash +cento demo-evidence record \ + --factory-run workspace/runs/factory/ \ + --task \ + --worker \ + --title "Factory task demo" \ + --duration 15 \ + --notes "Shows the completed user flow and visible validation result" +``` + +Verify a receipt before handoff: + +```bash +cento demo-evidence verify workspace/runs/factory//tasks//evidence/demo- +``` + +## Output Layout + +Default output without Factory metadata: + +```text +workspace/runs/demo-evidence/-<timestamp>/ + demo.mp4 + receipt.json + summary.md +``` + +With `--factory-run` and `--task`, output is colocated with the task: + +```text +workspace/runs/factory/<run-id>/tasks/<task-id>/evidence/demo-<timestamp>/ + demo.mp4 + receipt.json + summary.md +``` + +`summary.md` is the human review entry point. `receipt.json` is the validator-friendly artifact and includes: + +- schema version `cento.demo_evidence.v1` +- status, title, notes, tags, worker, Factory run, and task id +- requested duration and allowed duration window +- measured duration from `ffprobe` when available +- recorder backend and planned command +- video size and SHA-256 hash for completed captures + +## Worker Contract + +Builders and Codex workers should record demo evidence after the focused validation command passes and before moving a task to Validator or release handoff. + +Use this checklist: + +1. Prepare the app or terminal UI in the state the reviewer should inspect. +2. Run `cento demo-evidence record --duration 10` to `--duration 30`. +3. Keep the clip focused on one accepted flow or one clear before/after result. +4. Run `cento demo-evidence verify <run-dir>`. +5. Reference both `summary.md` and `receipt.json` in the handoff evidence list. + +Do not use this tool to capture secrets, tokens, private messages, or unrelated desktop windows. If the UI contains sensitive content, switch to sanitized fixture data before recording. + +## Recorder Backends + +`--recorder auto` is the default. + +- Linux Wayland: uses `wf-recorder` when available. +- Linux X11: uses `ffmpeg` with `x11grab`. +- macOS: uses `ffmpeg` with `avfoundation`; the Terminal or agent host may need Screen Recording permission. +- Synthetic: `--recorder synthetic` creates a generated ffmpeg test pattern for plumbing checks only. It is not product evidence. + +For X11, pass `--geometry WIDTHxHEIGHT+X,Y` to capture a smaller region: + +```bash +cento demo-evidence record --duration 12 --geometry 1280x720+0,0 +``` + +## Dry Runs And Smoke Tests + +Plan the command without recording: + +```bash +cento demo-evidence record --duration 15 --dry-run --json +``` + +Smoke-test the receipt and verification path without desktop capture: + +```bash +cento demo-evidence record \ + --duration 10 \ + --recorder synthetic \ + --out workspace/runs/demo-evidence/smoke \ + --json + +cento demo-evidence verify workspace/runs/demo-evidence/smoke +``` + +## Troubleshooting + +- `x11grab recorder requires DISPLAY`: run from a graphical X11 session or pass the command through the Linux desktop node. +- `no Linux screen recorder found`: install `ffmpeg` for X11 or `wf-recorder` for Wayland. +- macOS permission failures: grant Screen Recording permission to Terminal, the agent host, or the ffmpeg launcher, then rerun the command. +- duration verification failed: rerecord with `--duration` between 10 and 30 seconds. Very small encoder rounding differences are tolerated by `verify`. diff --git a/docs/dev-pipeline-redirect-scenario.md b/docs/dev-pipeline-redirect-scenario.md new file mode 100644 index 0000000..92ae41a --- /dev/null +++ b/docs/dev-pipeline-redirect-scenario.md @@ -0,0 +1,20 @@ +# Run Pipeline Compatibility Scenario + +Open this URL after the app is running. It should land on the Run Pipeline modal +with the prompt already filled into `operator-thoughts`: + +```text +http://127.0.0.1:47910/issues/new?prompt=Build%20a%20hard%20proreq%20plan%20from%20these%20operator%20notes%20and%20keep%20the%20frontend%20screenshot%20lane%20muted. +``` + +Click `Run pipeline`. + +Expected result: + +- Browser redirects to `/dev-pipeline-studio#pipeline-flow`. +- Execution Flow shows the hard-proreq route. +- No Taskstream issue is created for the prompt. +- The run records the five hard-proreq inputs: operator thoughts, generated Cento context, muted UI screenshot request, GPT Pro backend schema, and backend work handoff. +- Run artifacts appear under `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/`. + +For retries, change the prompt text before clicking `Run pipeline` so the run history is easy to distinguish. diff --git a/docs/dev-pipeline-run-contracts.md b/docs/dev-pipeline-run-contracts.md new file mode 100644 index 0000000..c75efae --- /dev/null +++ b/docs/dev-pipeline-run-contracts.md @@ -0,0 +1,93 @@ +# Dev Pipeline Run Input Contracts + +Dev Pipeline Studio now accepts prompt work through `POST /api/pipeline-runs` instead of creating a Taskstream issue. The request shape is: + +```json +{ + "schema_version": "cento.pipeline_run_request.v1", + "project_id": "hard-proreq-project", + "template_id": "hard-proreq-task", + "delivery_mode": "closed-loop", + "inputs": [] +} +``` + +`inputs` must match the selected template input IDs in order. Missing required user inputs, unknown input IDs, wrong kinds, and fields that do not belong to the input kind are rejected before a run starts. +`delivery_mode` is optional. ProReq-light defaults to `closed-loop`; `plan-only` is the escape hatch when an operator needs artifacts without worker dispatch. + +Supported input kinds are `questionnaire`, `path`, `image`, `details`, and `evidence`. Each input declares `source: "user"` or `source: "auto"`. User inputs carry submitted values. Auto inputs are generated by the pipeline and only declare their ID, kind, and source in the run request. + +Hard-proreq defaults: + +- `operator-thoughts`: user questionnaire, prefilled from `/issues/new?prompt=...`. +- `generated-cento-context`: auto `cento-context`. +- `ui-screenshot-request`: auto `openai-image`, muted and non-blocking; the Run Pipeline modal can include an optional local screenshot path as extra context. +- `pro-backend-schema`: auto schema artifact. +- `backend-work-handoff`: auto evidence/handoff artifact with ten story manifests, a parallel patch workset, integration policy, validation plan, and evidence bundle. + +ProReq-light defaults: + +- `project_id`: `proreq-light-project`. +- `template_id`: `proreq-light-task`. +- Inputs match Hard ProReq so existing request payloads can switch templates without reshaping operator input. +- The Pro planning step is `dispatch-codex-pro-backend-plan` instead of `dispatch-pro-backend-plan`. +- The planner writes `proreq_light_codex_prompt.md`, `proreq_light_output_schema.json`, `proreq_light_codex_command.json`, `proreq_light_codex_stdout.txt`, `proreq_light_codex_stderr.txt`, `proreq_light_codex_response.json`, and the compatible downstream `pro_backend_plan.json`. + +`cento proreq-light all` runs the same artifact chain as Hard ProReq, but replaces the live Pro request with read-only `codex exec --output-schema` using a prompt that starts with `You're chatGPT Pro model for this Cento proreq-light run.` The Codex Exec schema file is the raw `cento.hard_proreq_backend_plan.v1` JSON Schema, so story manifests, worksets, backend materialization, integration planning, validation planning, and evidence handoff stay compatible. If Codex Exec is unavailable, times out, returns non-JSON, or returns invalid schema output, the run records the error artifacts and falls back to deterministic ten-story planning instead of silently blocking. + +`cento proreq-light deliver --max-parallel 3 --runtime-profile codex-fast --json` is the closed-loop patch delivery entrypoint. It reuses or regenerates the ProReq-light workset, runs `cento workset check --allow-creates`, launches local Codex workers with `--runtime local-command --runtime-profile codex-fast`, integrates accepted patches sequentially, applies only clean owned-path changes, runs final validation, and writes `closed_loop_delivery.json`, `closed_loop_validation.json`, `closed_loop_evidence.json`, `closed_loop_evidence.md`, and `closed_loop_incident.*` when blocked. + +ProReq-light worksets are local-Codex-only. Generated story manifests use `lane.agent: codex-exec`; workset tasks use `runtime: local-command` and `runtime_profile: codex-fast`; `api-openai` is not used for worker dispatch in this route. + +Parallel pipeline defaults: + +- `parallel-objective`: user questionnaire for goal, acceptance, and risk boundaries. +- `parallel-workstreams`: user path list for exclusive repo-relative write paths; each path becomes an independent workset task unless JSON workstreams are supplied. +- `parallel-read-context`: auto shared read context for all workers. +- `parallel-ui-config`: user details for max parallelism, runtime, budget, validation mode, and Execution Flow display policy. +- `parallel-integrator-gate`: auto evidence that all worker patches converge through one serialized integration lane. +- `parallel-validation-evidence`: auto validation, receipt, log, cost, and handoff evidence bundle. + +`parallel-pipeline` writes a `cento.workset.v1` manifest with `execution_model: "parallel"`, one task per exclusive write path, and `max_parallel` from `parallel-ui-config` when the answer includes `max_parallel: N`. Execution Flow shows a dedicated Parallel Workset panel with worker lanes, max parallelism, no-shared-file state, and the serialized integrator before validation and evidence handoff. + +Multipipeline ProReq chain defaults: + +- `multipipeline-objective`: user questionnaire for the operator-defined multipipeline goal, target areas, boundaries, and pass-to-pass handoff proof. +- `multipipeline-schedule-config`: structured controls, fixed to four ordered passes by default, with `hard-proreq-task` as the child pipeline and request-artifact mode for Pro/image work. +- `multipipeline-context`: auto Cento route context for Dev Pipeline, Hard ProReq, parallel pipeline, scripts, docs, tests, and tool registry paths. +- `ui-screenshot-request`: auto muted image prompt artifact for the four-pass execution UI. It can include an optional operator screenshot reference, but live image generation remains opt-in. +- `multipipeline-pro-request`: auto ChatGPT Pro request artifact for manifests, integration guidance, validation guidance, UI guidance, cost-aware AI guidance, and next steps. +- `multipipeline-evidence`: auto bundle linking schedule, four pass requests, four pass guidance artifacts, UI request, Pro request, roadmap, and validation status. + +`multipipeline-proreq-chain` executes as a real Dev Pipeline Studio run. It writes run-scoped artifacts under `execution/multipipeline/<run-id>/` and mirrors latest artifacts under `execution/multipipeline/latest/`. The parent run executes nine deterministic steps: intake, schedule, four sequential ProReq pass request artifacts, UI screenshot request, ChatGPT Pro request, and evidence/roadmap. The child ProReq passes are scheduled as `cento.pipeline_run_request.v1` artifacts by default; live child execution is a future dispatcher mode and should stay behind explicit operator approval and budget caps. + +Hard-proreq planning now normalizes every run to ten story workstreams. The generated artifacts include `story_index.json`, `stories/<story-id>.json`, `parallel_patch_workset.json`, `manifest_integration_policy.json`, `backend_work_manifest.json`, `integration_plan.json`, and `validation_plan.json`. Patch generation is parallel through the workset, while apply/integration is serialized and receipt-backed. + +Generated worksets must declare the correct path policy before promotion. Plain `cento workset check WORKSET` requires every write path to exist. Local-Codex-created file plans use `cento workset check WORKSET --allow-creates`; legacy API-worker-created file plans use `cento workset check WORKSET --runtime api-openai` or `--allow-creates`, and promotion must record which policy passed. + +The default night budget is a $10 target and $20 hard cap. Integration is deterministic first; if model review is needed, the configured ceiling is `gpt-4.1-mini` through the `api-mini-integrator` profile. Notification policy is muted. + +The hard-proreq screenshot lane uses the OpenAI Images edits endpoint when `OPENAI_API_KEY` is configured and a reference screenshot exists. It writes `image_generation_request.json`, `image_generation_response.json`, and, when generation completes, `generated_integrator_screenshot.png`. Missing key or missing reference screenshot records a skipped non-blocking status for only the muted screenshot lane. + +ProReq-light never calls the live Pro dispatch path or the image API dispatch path. It may still run the local `codex` CLI, so its controls are separate from OpenAI API image/Pro controls. Set `CENTO_PROREQ_LIGHT_SKIP_CODEX_EXEC=1` to force deterministic fallback for tests, incident response, or no-compute validation. + +Environment variables: + +- `OPENAI_API_KEY`: enables OpenAI image and optional Pro dispatch. +- `CENTO_OPENAI_IMAGE_MODEL`: image edits model, default `gpt-image-2`. +- `CENTO_HARD_PROREQ_REFERENCE_SCREENSHOT`: optional reference screenshot override. +- `CENTO_HARD_PROREQ_IMAGE_SIZE`: default `1024x1536`. +- `CENTO_HARD_PROREQ_IMAGE_QUALITY`: default `low`. +- `CENTO_HARD_PROREQ_IMAGE_TIMEOUT`: default `240`. +- `CENTO_PROREQ_LIGHT_CODEX_BIN`: override the `codex` executable used by ProReq-light. +- `CENTO_PROREQ_LIGHT_CODEX_TIMEOUT`: Codex Exec planner timeout in seconds, default `900`. +- `CENTO_PROREQ_LIGHT_SKIP_CODEX_EXEC`: when truthy, skip Codex Exec and write deterministic fallback ProReq-light artifacts. +- `CENTO_PROREQ_LIGHT_RUNTIME_PROFILE`: local worker runtime profile, default `codex-fast`. +- `CENTO_PROREQ_LIGHT_MAX_PARALLEL`: worker concurrency for closed-loop delivery, default `3`. +- `CENTO_PROREQ_LIGHT_WORKER_TIMEOUT`: per-worker timeout for closed-loop delivery, default `180`. +- `CENTO_PROREQ_LIGHT_DELIVERY_TIMEOUT`: whole closed-loop delivery timeout, default `1800`. +- `CENTO_PROREQ_LIGHT_NO_FULL_CHECK`: when truthy, skips `make check` in closed-loop final validation. + +Retry behavior is run-scoped: every Run Pipeline submission creates a fresh execution run and artifacts under `execution/hard-proreq/<run-id>/`, with latest copies under `execution/hard-proreq/latest/`. + +OpenAI basis: the Image API guide describes single-prompt generation and edits, image references, output size/quality/format, base64 image output, and `input_fidelity`; it also notes `gpt-image-2` handles image inputs at high fidelity automatically. See https://developers.openai.com/api/docs/guides/image-generation and https://developers.openai.com/api/reference/resources/images/methods/edit. diff --git a/docs/factory-1000-patch-swarm-roadmap.md b/docs/factory-1000-patch-swarm-roadmap.md new file mode 100644 index 0000000..3daa98a --- /dev/null +++ b/docs/factory-1000-patch-swarm-roadmap.md @@ -0,0 +1,120 @@ +# Factory 1,000 Patch Swarm Roadmap + +Cento Factory is moving toward this target: + +`1,000 parallel candidate patches -> manifest-driven integration -> mostly deterministic validation -> task done in seconds for $1-2 -> self-improve and repeat`. + +The six-hour factory-scale final test is the controlled proof path. It schedules 30 ProReq-light executions, logs 300 ProReq-light command calls, and runs 10 Patch Swarm fixture e2e milestones that generate 1,000 candidate patch receipts. ProReq-light remains local and API-safe by default, Patch Swarm stays fixture/candidate-receipt first, and any real apply remains behind Factory/Safe Integrator worktrees. + +## Milestones + +### 1. Coordinator kernel, cron, append-only ledgers + +- `exec-001` `coordinator-kernel`: define the factory-scale coordinator kernel and run contract. +- `exec-002` `cron-deadline-lock`: install deadline-aware cron with flock overlap prevention. +- `exec-003` `append-only-ledgers`: prove events, calls, metrics, and spend ledgers are append-only. + +### 2. ProReq-light batch runner and isolated run roots + +- `exec-004` `batch-runner`: select exactly one pending ProReq-light execution per tick. +- `exec-005` `isolated-run-roots`: keep each ProReq-light pipeline root away from the active Dev Pipeline Studio root. +- `exec-006` `call-ledger-contract`: record ten explicit ProReq-light command calls per execution. + +### 3. Patch Swarm ingestion from ProReq-light outputs + +- `exec-007` `proreq-output-ingestion`: normalize ProReq-light outputs into Patch Swarm milestone handoffs. +- `exec-008` `milestone-grouping`: bind every three ProReq-light executions to one Patch Swarm run. +- `exec-009` `candidate-receipt-linking`: link generated candidate receipts back to their ProReq-light inputs. + +### 4. Provider adapters for Codex/Claude/API candidate receipts + +- `exec-010` `codex-candidate-adapter`: shape Codex Exec patch proposals into `candidate_patch.v1` receipts. +- `exec-011` `claude-candidate-adapter`: shape Claude Code proposals into the same provider-neutral receipt. +- `exec-012` `api-candidate-adapter`: keep OpenAI API candidates behind explicit budget gates. + +### 5. Deterministic validation fanout and failure taxonomy + +- `exec-013` `validator-fanout`: run deterministic validation across candidate receipts. +- `exec-014` `failure-taxonomy`: classify schema, ownership, patch-shape, duplicate, and test failures. +- `exec-015` `quarantine-ledger`: append rejected candidates and reasons without mutating accepted evidence. + +### 6. Manifest-driven Safe Integrator queue + +- `exec-016` `integrator-queue`: queue selected winners for the Factory Safe Integrator. +- `exec-017` `worktree-apply-plan`: require apply through Factory/Safe Integrator worktrees only. +- `exec-018` `rollback-receipts`: attach rollback and validation receipts to every integration plan. + +### 7. Cost/latency admission controller + +- `exec-019` `cost-admission`: reject live provider fanout without explicit budget and hard cap. +- `exec-020` `latency-budget`: track seconds per candidate, selected patch, and validation tier. +- `exec-021` `duplicate-saturation`: stop candidate generation when duplicate clusters saturate. + +### 8. Dev Pipeline / Factory operator observability + +- `exec-022` `operator-status`: render log-derived status for the six-hour run. +- `exec-023` `factory-ui-state`: expose candidate counts, provider mix, and handoffs to Dev Pipeline state. +- `exec-024` `handoff-evidence`: keep operator handoff markdown current as a derived artifact. + +### 9. Self-improvement task generator + +- `exec-025` `improvement-miner`: mine failure taxonomy and metrics for self-improvement tasks. +- `exec-026` `task-generator`: draft bounded Agent Work follow-ups for repeated blockers. +- `exec-027` `promotion-gates`: promote only improvements with passing deterministic validation. + +### 10. 1,000-patch Factory pilot and scale report + +- `exec-028` `thousand-candidate-pilot`: complete ten fixture Patch Swarm runs for 1,000 candidates. +- `exec-029` `scale-report`: summarize cost, latency, validation, and integration readiness. +- `exec-030` `repeat-loop`: feed the next self-improvement loop from the scale report. + +## Run Contract + +Start a default six-hour run: + +```bash +cento walk-autopilot factory-scale start --duration-hours 6 --proreq-executions 30 --min-proreq-calls 100 --patch-swarm --json +``` + +Cron uses the managed marker `# BEGIN CENTO FACTORY SCALE FINAL TEST`, runs every 12 minutes, uses `flock`, and checks the run deadline before each tick. Each tick selects one pending ProReq-light execution and appends ten command-call records: + +- `intake` +- `context` +- `screenshot` +- `pro-request` +- `codex-plan` +- `backend-work` +- `integration-plan` +- `validation-plan` +- `deliver --no-full-check --json` +- `evidence` + +Every third ProReq-light execution runs: + +```bash +cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json +``` + +## Artifacts + +Runs live under `workspace/runs/walk-autopilot/factory-scale-<timestamp>/`: + +- `roadmap.md` +- `config.json` +- `execution-manifest.json` +- `events.jsonl` +- `thoughts.jsonl` +- `proreq-light-calls.jsonl` +- `metrics.jsonl` +- `spend-ledger.jsonl` +- `handoff.md` +- `cron.md` +- `proreq-executions/exec-001..exec-030/` +- `patch-swarm/milestone-01..milestone-10/` + +## Safety Boundaries + +- Default ProReq-light execution logs explicit local command calls in isolated roots; `--execute-proreq` is required to run the ProReq-light commands. +- Live OpenAI API, image API, and OpenAI API patch workers are not enabled by this final test. +- Patch Swarm fixture e2e writes candidate receipts and Safe Integrator handoffs; it does not apply selected patches. +- Main-worktree mutation is allowed only through Factory/Safe Integrator worktrees after explicit validation gates. diff --git a/docs/factory-integration.md b/docs/factory-integration.md index 02dca12..967289f 100644 --- a/docs/factory-integration.md +++ b/docs/factory-integration.md @@ -1,6 +1,6 @@ # Factory Integration -Factory integration is the Safe Integrator layer for Cento Factory. It turns collected patch bundles into an isolated release candidate without merging to main automatically. +Factory integration is the Safe Integrator layer for Cento Factory. It turns collected patch bundles into an isolated release candidate. Automatic main merge is available only through the explicit `factory merge --auto-merge-main` gate. The flow is deterministic and no-model by default: @@ -14,15 +14,17 @@ cento factory integrate factory-integration-e2e \ --validate-each \ --limit 3 cento factory validate-integrated factory-integration-e2e +cento factory validate-fanout factory-integration-e2e --max-parallel 32 --json cento factory release-candidate factory-integration-e2e +cento factory merge factory-integration-e2e --auto-merge-main --push --json cento factory sync-taskstream factory-integration-e2e --dry-run ``` ## Policy -Safe Integrator creates an integration worktree under `workspace/factory-integration-worktrees/<run-id>/` and records the branch in `integration/integration-branch.json`. Patches are applied there one at a time. Main is not touched, and Taskstream is not moved to Done. +Safe Integrator creates an integration worktree under `workspace/factory-integration-worktrees/<run-id>/` and records the branch in `integration/integration-branch.json`. Patches are applied there one at a time. Main is not touched unless the separate auto-merge gate passes, and Taskstream is not moved to Done. -`apply-plan.json` orders candidate patches by dependency and risk. The applier runs `git apply --check`, applies a patch, runs per-patch validation when `--validate-each` is passed, then records a checkpoint. Failed patches are quarantined under `integration/quarantine/<task-id>/` with the failure reason and recovery recommendation. +`apply-plan.json` orders candidate patches by dependency and risk. `validate-fanout` runs cacheable deterministic checks in parallel before apply. The applier runs `git apply --check`, applies a patch, runs per-patch validation when `--validate-each` is passed, then records a checkpoint. Failed patches are quarantined under `integration/quarantine/<task-id>/` with the failure reason and recovery recommendation. ## Artifacts @@ -33,12 +35,16 @@ Safe Integrator creates an integration worktree under `workspace/factory-integra - `integration/applied-patches.json` - `integration/rejected-patches.json` - `integration/validation-after-each-patch.json` +- `integration/validation-fanout.json` +- `integration/validation-cache/<cache-key>.json` - `integration/quarantine/<task-id>/failure.json` - `integration/rollback-plan.json` - `integration/registry-gate.json` - `integration/merge-readiness.json` - `integration/taskstream-sync-preview.json` - `integration/release-candidate.md` +- `integration/merge-receipt.json` +- `integration/push-receipt.json` - `integration/integration-summary.html` - `integration/residual-risks.md` @@ -48,7 +54,9 @@ The integration gate rejects patches that are missing, fail `git apply --check`, `rollback-plan.json` contains reverse patch commands for the integration worktree. It is required for `integration-state.json` validation and release candidate readiness. -`merge-readiness.json` is machine-readable. A ready decision means the integration branch is prepared, patches were applied, validation passed, registry gates passed, and no rejected patches remain. A human/operator still performs the final merge review. +`merge-readiness.json` is machine-readable. `ready_for_human_merge_review` means the integration branch is prepared, patches were applied, validation passed, registry gates passed, and no rejected patches remain. `ready_for_auto_merge` is stricter and is produced only for the auto-merge path after rollback and fanout evidence are present. + +`merge --auto-merge-main` blocks unless main is clean, the current branch matches the target branch, release and rollback evidence exist, validation fanout passes, integrated validation approves, and pre/post validation commands pass. `--push` writes `push-receipt.json` only after local merge and post-merge validation. ## E2E diff --git a/docs/factory.md b/docs/factory.md index 68a9ab2..c466f12 100644 --- a/docs/factory.md +++ b/docs/factory.md @@ -23,8 +23,11 @@ cento factory integrate workspace/runs/factory/factory-planning-e2e --dry-run cento factory integrate factory-integration-e2e --plan cento factory integrate factory-integration-e2e --prepare-branch --branch factory/factory-integration-e2e/integration cento factory integrate factory-integration-e2e --apply --validate-each --limit 3 +cento factory validate-fanout factory-integration-e2e --max-parallel 32 --json cento factory validate-integrated factory-integration-e2e cento factory release-candidate factory-integration-e2e +cento factory merge factory-integration-e2e --auto-merge-main --dry-run --json +cento factory merge factory-integration-e2e --auto-merge-main --push --json cento factory sync-taskstream factory-integration-e2e --dry-run cento factory release workspace/runs/factory/factory-planning-e2e --json cento factory render-hub workspace/runs/factory/factory-planning-e2e @@ -76,6 +79,9 @@ Each run writes under `workspace/runs/factory/<run-id>/`: - `integration/applied-patches.json` - `integration/rejected-patches.json` - `integration/validation-after-each-patch.json` +- `integration/validation-fanout.json` +- `integration/validation-cache/<cache-key>.json` +- `integration/validation-fanout-log.jsonl` - `integration/quarantine/<task-id>/failure.json` - `integration/conflict-report.json` - `integration/rollback-plan.json` @@ -84,6 +90,8 @@ Each run writes under `workspace/runs/factory/<run-id>/`: - `integration/merge-readiness.json` - `integration/taskstream-sync-preview.json` - `integration/release-candidate.md` +- `integration/merge-receipt.json` +- `integration/push-receipt.json`, when `factory merge --push` succeeds or records a push block - `integration/integration-summary.html` - `integration/residual-risks.md` - `evidence/validation-summary.json` @@ -115,6 +123,26 @@ Each run writes under `workspace/runs/factory/<run-id>/`: - `runtime/<task-id>/stderr.log` - `runtime/<task-id>/patch/patch.json` - `runtime/<task-id>/collect-result.json` +- `tasks/<task-id>/evidence/demo-<timestamp>/demo.mp4`, when short demo evidence is recorded +- `tasks/<task-id>/evidence/demo-<timestamp>/receipt.json`, when short demo evidence is recorded + +## Demo Evidence + +Factory workers and Codex workers can attach short visual proof to a task with `cento demo-evidence`. Use it after focused validation passes and before Validator or release handoff when the reviewer needs to see a real UI or terminal flow. + +```bash +cento demo-evidence record \ + --factory-run workspace/runs/factory/<run-id> \ + --task <task-id> \ + --worker <worker-id> \ + --title "Factory task demo" \ + --duration 15 \ + --notes "Shows the accepted flow" + +cento demo-evidence verify workspace/runs/factory/<run-id>/tasks/<task-id>/evidence/demo-<timestamp> +``` + +The tool enforces 10-30 second clips and writes `demo.mp4`, `receipt.json`, and `summary.md`. Use `--dry-run` to plan the recording command and `--recorder synthetic` only to smoke-test the evidence plumbing. See `docs/demo-evidence.md` for recorder backend and troubleshooting details. ## Guardrails @@ -132,7 +160,11 @@ Generated story manifests are validated with `scripts/story_manifest.py`. Genera `integrate --dry-run` writes an integration gate plan in dependency order. It checks patch presence, owned paths, protected shared files, `git apply --check` when a patch exists, docs/tool registry alignment, conflicts, validation results, and rollback metadata. -`integrate --plan`, `--prepare-branch`, and `--apply --validate-each` are the factory integration Safe Integrator commands. They create an isolated integration worktree, apply candidate patch bundles one at a time, run validation after each patch, quarantine failures, write `rollback-plan.json`, update `merge-readiness.json`, and render `release-candidate.md`. They do not merge to main. +`integrate --plan`, `--prepare-branch`, and `--apply --validate-each` are the factory integration Safe Integrator commands. They create an isolated integration worktree, append apply events, apply candidate patch bundles one at a time, run validation after each patch, quarantine failures, write `rollback-plan.json`, update `merge-readiness.json`, and render `release-candidate.md`. + +`validate-fanout` runs cacheable deterministic candidate checks in parallel before the serialized apply path becomes the bottleneck. It writes `integration/validation-fanout.json` and `integration/validation-cache/`, keyed by base SHA, patch hash, and validation suite. + +`merge --auto-merge-main` is the only automatic main-merge gate. It requires passing Safe Integrator state, release candidate evidence, rollback metadata, validation fanout, a clean main worktree, and the expected target branch. `--dry-run` writes the same merge readiness receipt without merging or pushing. With `--push`, it pushes only after local merge and post-merge validation write `merge-receipt.json` and `push-receipt.json`. `sync-taskstream --dry-run` writes `integration/taskstream-sync-preview.json`. It previews Review or Blocked transitions from integration results but does not mark Factory tasks Done. diff --git a/docs/fixture-sickness.md b/docs/fixture-sickness.md new file mode 100644 index 0000000..888814e --- /dev/null +++ b/docs/fixture-sickness.md @@ -0,0 +1,232 @@ +# Fixture Sickness + +## Definition + +Fixture Sickness is the Cento failure mode where an agent repeatedly creates new fixtures, schemas, manifests, contracts, dashboards, or evidence bundles instead of using existing Cento surfaces and previously produced evidence to execute real work. + +It looks productive because it produces files. + +It is dangerous because it avoids integration. + +In Patch Swarm terms, Fixture Sickness is what happens when the system proves it can coordinate delivery, then the next plan restarts from zero with another runtime contract, another schema family, another fixture E2E, and another evidence stack instead of consuming the closeout evidence and running one real task through the existing surfaces. + +## Symptoms + +- Planning another runtime contract after a runtime contract already exists. +- Creating another schema instead of consuming `factory`, `build`, `workset`, or `parallel-delivery` artifacts. +- Creating another fixture E2E instead of running a real non-fixture pilot. +- Treating evidence production as delivery. +- Repeating recon and gap maps every phase without using the prior map. +- Adding new `workspace/runs/...` families that nobody consumes. +- Rebuilding task graphs, leases, queues, inboxes, and release candidates while Factory, Build, Workset, Parallel Delivery, and Agent Work already have them. +- Proposing new tools before checking `cento tools`, `cento docs`, `data/tools.json`, and prior evidence. +- Confusing "we can generate a demo" with "the system can do work." +- Designing the next phase from scratch instead of asking how to use the closeout artifacts. + +## Why It Happens + +- Agents optimize for producing visible artifacts. +- Fixtures are safer than real execution. +- Contracts feel like progress. +- New schemas avoid the harder work of adapter and integration. +- Long multi-call plans drift toward scaffolding. +- Evidence becomes an end in itself. +- Lack of a reuse gate allows every phase to restart at zero. + +## Why It Is Harmful + +- It destroys trust. +- It burns Pro and Codex calls. +- It duplicates workflows. +- It increases maintenance burden. +- It hides the fact that real execution is not improving. +- It makes Cento look like a museum of receipts instead of a delivery system. +- It violates Cento's routing rule: prefer existing surfaces first. +- It prevents the system from using its own outputs. + +## Existing Surfaces That Must Be Reused First + +- `cento parallel-delivery`: Patch Swarm coordinator for plan, execute, demo, validate, status, and patch-swarm E2E. +- `cento factory`: intake, no-model planning, queues, prompt bundles, patch collection, validation, integration dry-runs, release candidates, and evidence hubs. +- `cento build`: owned-path build packages, builder prompts, patch bundles, dry-run integration, safe apply, and receipts. +- `cento workset`: exclusive-path local N-worker execution and sequential integration. +- `cento agent-work`: Taskstream story and validation manifests plus task lifecycle. +- `cento agent-pool-kick`: bounded worker launch, with dry-run behavior before live launch. +- `cento agent-processes`: process and worker visibility. +- `cento temp`: short-lived operator command and clipboard bridge. +- `cento demo-evidence`: low-memory demo video receipts. + +These are not references to admire. They are surfaces to consume before creating anything new. + +## The Reuse Gate + +Before proposing a new fixture, schema, manifest, contract, tool, command, evidence family, or 10+ call plan, an agent must answer: + +1. What existing command already does this? +2. What existing artifact already represents this state? +3. What existing evidence proves it already worked? +4. What existing tool should consume this next? +5. Can this be done by connecting Factory, Build, Workset, Parallel Delivery, or Agent Work? +6. Is this new thing actually needed, or are we avoiding a real run? +7. What is the smallest adapter that would let us use the existing result? +8. What prior run directory should this continue from? +9. What exact command will consume the previous output? +10. What user-visible outcome happens after reuse? + +If the answers are missing, the proposal is rejected as Fixture Sickness. + +## Patch Swarm Reuse Gate + +- [ ] Did I inspect `cento tools`? +- [ ] Did I inspect `cento docs parallel-delivery`? +- [ ] Did I inspect `cento docs factory`? +- [ ] Did I inspect `cento docs build`? +- [ ] Did I inspect `cento docs workset`? +- [ ] Did I inspect prior evidence under `workspace/runs/parallel-delivery`? +- [ ] Did I identify the previous run directory I am continuing from? +- [ ] Did I identify the exact existing artifact I am consuming? +- [ ] Did I identify the exact existing command that consumes it? +- [ ] Did I try an existing command before proposing a new one? +- [ ] Did I prefer adapter over schema? +- [ ] Did I prefer real pilot over fixture? +- [ ] Did I avoid creating a new durable workflow? +- [ ] Did I avoid creating a new fixture unless it protects a real command? +- [ ] Did I avoid changing registry, docs, or Makefile unless a durable user-facing surface changed? +- [ ] Did I preserve dirty work? +- [ ] Did I keep secrets local? +- [ ] Did I define the user-visible next action? + +If any required item is unchecked, stop and explain why. + +## Forbidden Planning Patterns + +Bad: + +> Create a new runtime schema for worker queues. + +Better: + +> Inspect existing Factory queue and Patch Swarm worker-packet artifacts; add an adapter only if no existing field supports the next command. + +Bad: + +> Create a new E2E fixture proving 100 agents. + +Better: + +> Use the existing 100-candidate fixture only as a regression gate, then run one real non-fixture pilot with live workers disabled or dry-run. + +Bad: + +> Create a new release-candidate packet. + +Better: + +> Use existing Factory or Patch Swarm release-candidate artifacts; add a missing evidence pointer if needed. + +Bad: + +> Create a new task manifest format. + +Better: + +> Use the existing Agent Work story/validation manifest format or Factory task artifacts. + +Bad: + +> Create another 30-call foundation. + +Better: + +> Start from the 20-call closeout evidence and ask what command consumes it next. + +## Correct Next-Phase Principle + +The next phase after the 20-call Patch Swarm closeout should be: + +USE THE SYSTEM ON ITSELF. + +Not: + +- another fixture suite +- another schema family +- another runtime plan +- another one-pager + +But: + +- select one real low-risk Cento task +- use existing `parallel-delivery`, `factory`, `build`, `workset`, or `agent-work` surfaces +- generate or reuse work packages +- run one or two real Codex workers manually if needed +- collect actual patches +- validate through existing safety gates +- dry-run integration +- produce release evidence +- write a postmortem of missing reuse gaps + +## The Use Before Build Rule + +Before creating anything new: + +1. Try to run an existing command. +2. Try to consume an existing artifact. +3. Try to adapt an existing format. +4. Try to add a thin bridge. +5. Only then create a new durable artifact or tool. + +## What Future ChatGPT Pro Calls Must Do + +Future Pro calls must not default to "create a new implementation packet." + +They must start with: + +- Which previous run are we continuing? +- Which existing artifact is the input? +- Which existing command consumes it? +- What is the smallest next action? +- What will be real, not fixture? + +## Examples For Patch Swarm + +Instead of: + +> Call 1: runtime contract. + +Do: + +> Call 1: consume the 20-call closeout and identify the first real task to run through existing `parallel-delivery` or `factory`. + +Instead of: + +> Call 2: runtime schema. + +Do: + +> Call 2: map existing Factory, Build, and Workset artifacts to the Patch Swarm closeout artifacts and identify only missing adapters. + +Instead of: + +> Call 3: fixture E2E. + +Do: + +> Call 3: run the existing fixture E2E once as a regression gate, then run a real non-fixture dry-run plan. + +## Required AI Behavior + +Future agents must: + +- read this document before planning Patch Swarm, Factory, Build, or Workset expansions +- cite or mention the Reuse Gate in their plan +- reject duplicate fixture proposals +- prefer adapters over new formats +- prefer real pilot runs over new proof fixtures +- treat evidence as an input to the next command, not as a terminal artifact +- preserve dirty work +- keep secrets local +- avoid direct Taskstream database mutation + +## Closeout + +Cento is not allowed to become a museum of beautiful receipts. Evidence must drive the next action. diff --git a/docs/industrial-pet.md b/docs/industrial-pet.md new file mode 100644 index 0000000..610a4e2 --- /dev/null +++ b/docs/industrial-pet.md @@ -0,0 +1,47 @@ +# Darth Lolipopus Pet Pane + +`cento industrial-pet` opens a Bubble Tea pane for Darth Lolipopus, the Cute Sith pet used by the Industrial OS workspace. + +## Commands + +```bash +cento industrial-pet +cento industrial-pet --once --width 98 --height 24 +cento industrial-pet --action nap +cento industrial-pet --image assets/industrial-os/darth-lolipopus.png +cento industrial-pet --portrait slot +cento industrial-pet --reset +``` + +## Automation Flags + +- `--once` renders once and exits. +- `--action ACTIVITY_ID` performs one activity and saves state. +- `--state PATH` overrides the pet state path. +- `--database PATH` overrides the activity/comment database. +- `--image PATH` overrides the Darth Lolipopus portrait image. +- `--portrait ansi|slot|none` chooses terminal-pixel portrait, reserved image slot, or no portrait. +- `--reset` resets state for Darth Lolipopus. +- `--width` and `--height` make non-interactive renders deterministic. + +## State + +The default state path is: + +```text +${XDG_STATE_HOME:-~/.local/state}/cento/industrial-os/darth-lolipopus.json +``` + +Tests and automation should pass `--state` and `--database` so validation does not touch the operator's live pet state. + +## Controls + +- `j` / `k`: select an activity. +- `1`-`6`: perform a quick activity. +- `enter`: perform the selected activity. +- `r`: refresh state and elapsed-time decay. +- `q`: quit. + +Industrial OS launches this pane as `cento-industrial-pet` in the bottom-left tile of the workspace. +The default portrait is `assets/industrial-os/darth-lolipopus.png`, matching the rofi launcher side art. +The workspace pane uses `assets/industrial-os/darth-lolipopus-pane.png` as a high-resolution Kitty background and starts the TUI with `--portrait slot`, so the live pane shows the real bitmap instead of a low-resolution terminal-cell copy. diff --git a/docs/kanji-a-day.md b/docs/kanji-a-day.md new file mode 100644 index 0000000..aa853bf --- /dev/null +++ b/docs/kanji-a-day.md @@ -0,0 +1,90 @@ +# Kanji a Day + +Kanji a Day is a watch-friendly local learning app for one daily kanji. + +```text +Today -> stroke practice -> meaning -> Got it -> history +``` + +The Docs route now treats the app page as a Product Control Surface, not a static article. It follows `app_overview_page_v1` so an operator or agent can understand state, open entry points, validate behavior, and continue work from one page. + +## Current State + +- Status: `development` +- Version: `0.3.0` +- Updated: `2026-05-01` +- Environment: local preview +- Live preview: `http://127.0.0.1:47924/` +- Taskstream epic: `1000110` +- Implementation branch: `codex/kanji-pwa-learning-loop` + +## Page Contract + +The `/docs#kanji-a-day` page must include: + +- Header: app identity, description, status, version, updated date +- Control Strip: live app, repository, and Taskstream links +- Project Dashboard: status, version, environment, validation date, daily lesson count, kanji set size, subscription state +- About: what the app does every day and the core mechanics +- Current Release: actual version, build, date, and shipped notes +- System Architecture: readable pipeline from PWA preview to local storage +- Operations: Taskstream, preview, validation, and architecture entry points +- Links + Entry Points: User Guide, Data Model, Changelog, and PR + +## Product Rules + +- The learner should see the stroke order before the meaning is treated as complete. +- `Meaning` stays locked until all strokes finish. +- `Learned` increments only after the user confirms with `Got it`. +- Normal user mode must not show internal language such as MVP, PWA, AI calls, reset, localhost, or backend notes. +- Debug controls belong behind `?debug=1`. + +## Content + +The embedded starter set is: + +- `日` - sun, day +- `月` - moon, month +- `火` - fire +- `水` - water +- `木` - tree, wood +- `金` - gold, money +- `土` - earth, soil + +Each item should include meaning, reading, example vocabulary, stroke count, and SVG stroke path data. + +## Architecture + +```text +PWA Preview -> Stroke Player -> Kanji Dataset -> Local Storage +``` + +- PWA Preview: watch-style compact UI under `workspace/runs/agent-work/1000104/public/` +- Stroke Player: sequential SVG stroke playback and replay +- Kanji Dataset: seven embedded beginner records +- Local Storage: learned history, streak state, and current kanji progress + +## Validation + +Use Firefox responsive design mode or screenshot automation to check: + +- `/docs#kanji-a-day` renders the Product Control Surface +- desktop layout has dense operational cards and a right action column +- 360px, 390px, and 430px widths have no horizontal clipping +- Today screen has no overlapping header text +- Stroke completion keeps Replay and Meaning fully inside the watch frame +- Meaning screen centers the kanji and keeps `Got it` visible inside the watch frame +- Normal app mode contains no debug or MVP copy +- `?debug=1` exposes debug-only controls when needed + +Reference screenshot: + +```text +/home/alice/Downloads/kanji a day.png +``` + +Recent app visual evidence lives under: + +```text +workspace/runs/agent-work/1000104/screenshots/ +``` diff --git a/docs/manifest-driven-web-app.md b/docs/manifest-driven-web-app.md new file mode 100644 index 0000000..2c46512 --- /dev/null +++ b/docs/manifest-driven-web-app.md @@ -0,0 +1,19 @@ +# Manifest-Driven Web App + +<p><strong>Idea:</strong> Cento Docs, and eventually the broader Cento web app, could be generated from manifests and event streams rather than hand-wired page-by-page behavior.</p> + +<p>Instead of running Cento ad hoc for every docs page or custom web surface, Cento would define clear processes, artifacts, boundaries, scopes, and acceptance rules. The web app would then autowire Taskstream execution results, Factory outputs, build receipts, validation evidence, and deliverables into Docs.</p> + +<p>This mirrors the autonomous development system direction: manifest first, owned scope, explicit artifacts, deterministic validation, receipts, and evidence-backed rendering.</p> + +<p>Docs pages would become views over durable manifests and event streams. A page could declare what it reads, what artifacts it displays, what commands it references, what evidence is required, and which boundaries it must not cross.</p> + +<p>Ad hoc Docs functionality could still exist, but it should be manifest-shaped where practical: inputs, outputs, allowed commands, owned paths, validation rules, and render targets should be explicit.</p> + +<p>The long-term goal is <strong>zero-AI or close-to-zero-AI regeneration</strong> for Docs and possibly the entire Cento web app. Cento should be able to regenerate large parts of the app from manifests, receipts, and event streams without asking an AI model to reinterpret intent every time.</p> + +<p>This is not a commitment to implement the web app exactly this way. It is a design direction worth preserving: make the web app a deterministic projection of Cento's manifest-driven operating system, not a collection of manually maintained one-off pages.</p> + +<p>Possible future primitives: docs manifest, page manifest, render manifest, command manifest, artifact manifest, event-stream binding, Taskstream result binding, Factory run binding, validation receipt binding, and build receipt binding.</p> + +<p>Useful acceptance question: could this page be regenerated from declared inputs and receipts without hidden context?</p> diff --git a/docs/nav.html b/docs/nav.html index 7c1564d..6617213 100644 --- a/docs/nav.html +++ b/docs/nav.html @@ -30,6 +30,10 @@ #search { flex: 1; min-width: 200px; background: var(--surface2); border: 1px solid var(--border); color: var(--text); padding: 7px 12px; border-radius: 4px; font-family: inherit; font-size: 13px; outline: none; } #search:focus { border-color: var(--accent); } #search::placeholder { color: var(--muted); } + .doc-links { display: flex; gap: 8px; padding: 10px 24px; border-bottom: 1px solid var(--border); flex-wrap: wrap; align-items: center; } + .doc-links span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; } + .doc-links a { color: var(--accent2); border: 1px solid var(--border); background: var(--surface); padding: 5px 9px; border-radius: 3px; text-decoration: none; font-size: 11px; } + .doc-links a:hover { border-color: var(--accent2); color: #d1efff; } .lanes { display: flex; gap: 6px; flex-wrap: wrap; } .lane-btn { background: var(--surface2); border: 1px solid var(--border); color: var(--muted); padding: 5px 10px; border-radius: 3px; cursor: pointer; font-family: inherit; font-size: 11px; transition: all 0.15s; } @@ -129,6 +133,23 @@ <div class="lanes" id="lane-filters"></div> </div> +<div class="doc-links" aria-label="Human docs"> + <span>human docs</span> + <a href="./ai-self-improvement-log.md">AI self-improvement log</a> + <a href="./ai-self-improvement-autopilot.md">AI self-improvement autopilot</a> + <a href="./ai-review-unblock-autopilot.md">AI review/unblock autopilot</a> + <a href="./ai-routing-nativeness-loop.md">AI routing nativeness loop</a> + <a href="./parallel-integration-train.md">Parallel integration train</a> + <a href="./patch-swarm.md">Patch Swarm</a> + <a href="./factory-1000-patch-swarm-roadmap.md">Factory 1,000 Patch Swarm</a> + <a href="./tool-foundry.md">Tool Foundry</a> + <a href="./client-intake-hub.md">Client Intake Hub</a> + <a href="./walk-autopilot-spend-cap-incident.md">Walk spend incident</a> + <a href="./oci-image-migration.html">OCI image migration</a> + <a href="./storage.md">Storage</a> + <a href="./tool-index.md">Tool index</a> +</div> + <div class="stats" id="stats"></div> <div class="section-tabs"> @@ -146,9 +167,9 @@ </div> <script> -const TOOLS = {"tools":[{"id":"cento-cli","name":"Cento CLI","lane":"general ops","kind":"shell","entrypoint":"./scripts/cento.sh","wrapper":"~/bin/cento","description":"Unified cento facade for built-ins, terminal docs browsing, tool dispatch, and user-defined aliases.","platforms":["linux","macos"],"commands":["cento help","cento interactive","cento docs","cento docs conf","cento docs --json","cento docs --path","cento tools","cento aliases","cento conf","cento conf --path","cento completion zsh","cento install all","cento install zsh","cento install tmux","cento run scan --query \"mcp\"","cento platforms","cento platforms macos","cento platforms linux","cento platforms --markdown"],"outputs":["~/.config/cento/aliases.sh","~/.config/cento/init.zsh","~/.config/cento/tmux.conf"],"notes":["Canonical built-in docs live in data/cento-cli.json.","Use cento interactive for the Bubble Tea terminal browser of built-ins, tools, and aliases.","Use cento docs for the non-interactive JSON-backed docs path.","Combined aliases can chain multiple cento subcommands through bash -lc.","Use cento platforms to compare declared macOS and Linux support."]},{"id":"bridge","name":"OCI SSH Bridge","lane":"remote access","kind":"shell","entrypoint":"./scripts/bridge.sh","description":"Create a reverse SSH tunnel through the OCI VM so another machine can SSH back into this host through the VM relay.","platforms":["linux","macos"],"commands":["cento bridge start","cento bridge status","cento bridge stop","cento bridge restart","cento bridge foreground","cento bridge command","cento bridge mac-command","cento bridge docs","cento bridge check","cento bridge from-mac","cento bridge --from-mac","cento bridge expose-linux","cento bridge install-linux-service","cento bridge install-mac-service","cento bridge expose-mac","cento bridge to-linux","cento bridge to-mac","cento bridge mesh-status","cento bridge context-linux","cento bridge context-mac"],"outputs":["~/.local/state/cento/bridge.pid","~/.local/state/cento/bridge.log"],"notes":["Defaults to the OCI instance opc@129.213.17.199 from instance-20250511-1002.","Uses ~/.ssh/id_ed25519 as the default private key for that VM.","Requests a localhost reverse tunnel on the VM: 127.0.0.1:2222 -> this machine 127.0.0.1:22.","The Mac connects with ProxyJump through the VM instead of using a public relay port.","Use cento bridge check to validate the local repo and Mac-through-VM SSH path.","Secure mesh mode uses SSH remote Unix sockets on the VM instead of public VM TCP listeners.","Use expose-linux on the Linux node and expose-mac on the Mac node, then use to-linux/to-mac for node-to-node commands."]},{"id":"daily","name":"Daily Execution Support","lane":"execution","kind":"shell","entrypoint":"./scripts/daily_tui.sh","description":"Bubble Tea execution cockpit for morning brief, midday recalibration, evening wrap-up, and local continuity.","platforms":["linux","macos"],"commands":["cento daily"],"outputs":["workspace/runs/daily/history.json"],"notes":["Mock brief generation is isolated behind a BriefGenerator interface for later LLM replacement.","The launcher builds a cached Bubble Tea binary from scripts/daily_tui.go."]},{"id":"tui","name":"Telegram TUI","lane":"communications","kind":"shell","entrypoint":"./scripts/telegram_tui.sh","description":"Bubble Tea Telegram TUI with cached Go launcher, local config, and planned CRM hooks.","platforms":["linux","macos"],"commands":["cento tui","cento tui status","cento tui config --path","cento tui docs","cento crm integration --provider telegram"],"outputs":["~/.config/cento/telegram.json","workspace/runs/telegram-tui/*.md","workspace/runs/crm-app/<profile>/integration-telegram.md"],"notes":["This tool follows the repo TUI standard in standards/tui.md.","The launcher builds a cached Bubble Tea binary from scripts/telegram_tui.go.","CRM integration remains a registered placeholder under cento crm integration."]},{"id":"crm","name":"CRM Module","lane":"career consulting","kind":"python","entrypoint":"./scripts/crm_module.py","description":"Embedded cento CRM with questionnaire bootstrap, career-intake dossiers, local JSON persistence, and a self-hosted no-build SPA.","platforms":["linux","macos"],"commands":["cento crm","cento crm questionnaire","cento crm init","cento crm intake init --person \"Ada Lovelace\"","cento crm intake add --person \"Ada Lovelace\" --kind resume --file ./resume.pdf","cento crm intake plan --person \"Ada Lovelace\"","cento crm integration --provider redmine --person \"Ada Lovelace\" --start-workflow --dry-run","cento crm serve --open","cento crm show","cento crm docs"],"outputs":["workspace/runs/crm-questionnaire/<profile>/answers.json","workspace/runs/crm-questionnaire/<profile>/summary.md","workspace/runs/crm-app/<profile>/state.json","workspace/runs/crm-app/latest.json","workspace/runs/career-intake/<person>/manifest.json","workspace/runs/career-intake/<person>/artifact-plan.md","workspace/runs/career-intake/<person>/prompts/*.md","workspace/runs/career-intake/<person>/artifacts/*.md","Redmine project and issues via REST API"],"notes":["Run cento crm serve to host the local SPA through the cento CLI.","Run cento crm init to bootstrap app state from the saved questionnaire.","Run cento crm intake to collect raw candidate inputs and generate a Codex-ready artifact plan.","Run cento crm integration --provider redmine --start-workflow to create a Redmine workflow from generated artifacts.","The CRM is a no-build local app backed by JSON persistence."]},{"id":"burp","name":"Burp Suite Community","lane":"security testing","kind":"shell","entrypoint":"./scripts/burp_suite_community.sh","description":"Download, set up, and control PortSwigger Burp Suite Community through cento wrappers.","platforms":["linux"],"commands":["cento burp download","cento burp download --type linux","cento burp setup","cento burp controller start --use-defaults","cento burp run -- --help","cento burp status","cento burp stop","cento burp docs"],"outputs":["~/.local/share/cento/burp/downloads/*","~/.local/share/cento/burp/current/burpsuite_community.jar","~/.local/bin/burp-community","~/.local/share/cento/burp/install.env","~/.local/share/cento/burp/burp.pid","~/.local/share/cento/burp/burp.log"],"notes":["Downloads use PortSwigger's official latest Community Edition endpoints.","The default setup path installs the official JAR and generates a local burp-community launcher.","Use download --type linux to fetch the official Linux installer for later manual or automated installer work.","Burp Suite is a GUI application; controller start runs it in the background and records a local PID."]},{"id":"mcp","name":"MCP Tooling","lane":"general ops","kind":"python","entrypoint":"./scripts/mcp_tooling.py","description":"Manage repo-root MCP config, env templates, validation, and tool-call docs.","platforms":["linux","macos"],"commands":["cento mcp doctor","cento mcp init --write-env","cento mcp docs","cento mcp paths"],"outputs":[".mcp.json",".env.mcp.example",".env.mcp","mcp/*.md"],"notes":["The canonical shared config lives at the repo root in .mcp.json.","Machine-local secrets should live in environment variables or .env.mcp.","This tool follows standards/mcp.md."]},{"id":"scan","name":"Scan One Pager","lane":"general ops","kind":"python","entrypoint":"./scripts/scan_onepager.py","description":"Scan cento for a topic and generate an archived HTML one-pager with explanation and snippets.","platforms":["linux","macos"],"commands":["cento scan --query \"mcp\"","cento scan --query \"telegram\" --no-open","cento scan --query \"crm\" --case-sensitive","cento scan --query \"mcp\" --port 47890"],"outputs":["workspace/runs/scan-onepager/latest/index.html","workspace/runs/scan-onepager/latest/summary.json","workspace/runs/scan-onepager/archive/*","workspace/runs/scan-onepager/server.json"],"notes":["Each run archives the previous latest output before writing the new page.","The tool starts or reuses a local preview server on a high port and opens the browser by default.","See docs/scan-onepager.md for the command surface and output model."]},{"id":"bluetooth-audio-doctor","name":"Bluetooth Audio Doctor","lane":"general ops","kind":"python","entrypoint":"./scripts/bluetooth_audio_doctor.py","wrapper":"~/bin/codex-bt-audio-doctor","description":"Diagnose Bluetooth and Bluetooth-audio failures, generate detailed reports, and apply safe repair actions.","platforms":["linux"],"commands":["python3 ./scripts/bluetooth_audio_doctor.py \"Black Diamond\"","python3 ./scripts/bluetooth_audio_doctor.py \"Black Diamond\" --fix","python3 ./scripts/bluetooth_audio_doctor.py \"Black Diamond\" --fix --repair-pairing"],"outputs":["stdout Markdown report","~/bluetooth-audio-reports/*.md"],"notes":["The script is safe by default.","--repair-pairing removes the current Bluetooth bond and pairs again."]},{"id":"audio-quick-connect","name":"Audio Quick Connect","lane":"general ops","kind":"shell","entrypoint":"./scripts/audio_quick_connect.sh","description":"Quickly connect a paired Bluetooth audio device by name or address with a short retry path and per-run logs.","platforms":["linux"],"commands":["./scripts/audio_quick_connect.sh \"Black Diamond\"","./scripts/audio_quick_connect.sh \"Bose\"","cento audio-quick-connect \"Black Diamond\""],"outputs":["logs/audio-quick-connect/*.log"],"notes":["Matches paired devices by exact name, substring, or MAC address.","Verifies the target advertises Bluetooth audio capabilities before connecting.","Writes the latest run to logs/audio-quick-connect/latest.log."]},{"id":"dashboard","name":"Dashboard","lane":"general ops","kind":"python","entrypoint":"./scripts/dashboard_server.py","description":"Run a localhost web dashboard with current state, recent cento activity, aliases, tools, and repo progress.","platforms":["linux"],"commands":["./scripts/dashboard_server.py","./scripts/dashboard_server.py --open","./scripts/dashboard_server.py --theme industrial --open","./scripts/dashboard_server.py --host 127.0.0.1 --port 46268","cento dashboard"],"outputs":["logs/dashboard/*.log"],"notes":["Starts a local HTTP server on 127.0.0.1 by default.","Use --theme industrial for the Industrial OS dashboard skin.","Shows current theme, wallpaper, audio, displays, recent tool runs, aliases, tools, and git progress.","Use --open to launch it in your default browser."]},{"id":"preset","name":"Desktop Presets","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/preset.sh","description":"Apply managed Cento desktop presets such as the Industrial OS i3 theme and dashboard.","platforms":["linux"],"commands":["cento preset list","cento preset industrial-os","cento preset industrial-os --workspace","cento preset industrial-os --workspace --black-only","cento preset industrial-os --session","cento preset industrial-os --dashboard-only --open","cento dashboard --theme industrial --open"],"outputs":["~/.config/cento/preset.env","~/.config/cento/industrial-os/polybar/config.ini","~/.config/cento/industrial-os/rofi.rasi","~/.local/share/cento/industrial-os/wallpaper.png","~/.local/state/cento/industrial-os/dashboard.url","logs/industrial-os/*.log","logs/industrial-workspace/*.log"],"notes":["industrial-os writes a guarded block to ~/.config/i3/config so i3 reloads keep the preset active.","The preset applies the Cento Industrial OS Kitty theme, generated wallpaper, Polybar config, Rofi theme, and Picom config.","Mod+Shift+I runs --workspace and composes workspace 1 into the cockpit tile layout.","Use --black-only or CENTO_INDUSTRIAL_BACKGROUND_MODE=black for plain black workspace pane backgrounds.","--session reapplies runtime pieces without rewriting the i3 config and is intended for i3 startup."]},{"id":"quick-help","name":"Quick Help","lane":"general ops","kind":"shell","entrypoint":"./scripts/quick_help.sh","description":"Rofi-based searchable help palette for cento built-ins, tools, and aliases.","platforms":["linux"],"commands":["./scripts/quick_help.sh","./scripts/quick_help.sh --show","cento quick-help"],"outputs":["logs/quick-help/*.log"],"notes":["Uses rofi when available and follows your existing polybar rofi launcher theme when present.","Lets you search cento built-ins, registered tools, and aliases from one palette.","Can run the selected command or copy it to the clipboard."]},{"id":"display-layout-fix","name":"Display Layout Fix","lane":"general ops","kind":"shell","entrypoint":"./scripts/display_layout_fix.sh","description":"Detect two connected monitors, stack them vertically, and refresh wallpaper plus polybar.","platforms":["linux"],"commands":["./scripts/display_layout_fix.sh --show","./scripts/display_layout_fix.sh --save-defaults","./scripts/display_layout_fix.sh --top DP-4.8 --bottom HDMI-0 --save-defaults"],"outputs":["~/.config/cento/display.env","logs/display-layout-fix/*.log"],"notes":["Defaults to using the primary connected output as the top monitor.","Reapplies wallpaper and relaunches polybar after xrandr changes.","i3 startup now calls cento display-layout-fix instead of a hardcoded xrandr --right-of line."]},{"id":"i3reorg","name":"i3 Reorg","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/i3reorg.sh","description":"Move numeric i3 workspaces to the bottom monitor, apply the preferred app map, and optionally place the study YouTube window on top workspace L2 fullscreen.","platforms":["linux"],"commands":["./scripts/i3reorg.sh","./scripts/i3reorg.sh --dry-run","./scripts/i3reorg.sh --bottom-output DP-4.8","./scripts/i3reorg.sh --study","cento i3reorg","cento i3reorg --study","cento i3reorg --focus 2"],"outputs":["i3 workspace moves"],"notes":["Detects the active output with the largest y-position as the bottom monitor.","Study mode keeps a matching Firefox YouTube window on top workspace L2 fullscreen.","Uses i3 criteria against common Firefox, terminal, Discord, and Telegram window classes."]},{"id":"wallpaper-manager","name":"Wallpaper Manager","lane":"general ops","kind":"shell","entrypoint":"./scripts/wallpaper_manager.sh","description":"Choose, preview, apply, and persist desktop wallpapers for i3 and feh.","platforms":["linux"],"commands":["./scripts/wallpaper_manager.sh --choose","./scripts/wallpaper_manager.sh --set green_arctic.jpg","./scripts/wallpaper_manager.sh --apply-current","./scripts/wallpaper_manager.sh --list"],"outputs":["~/.config/cento/wallpaper.env","logs/wallpaper-manager/*.log"],"notes":["Uses the current i3 wallpaper library in ~/.config/kitty by default.","Interactive picker uses fzf and attempts Kitty-based preview when available.","i3 startup can call cento wallpaper-manager --apply-current to restore the saved wallpaper."]},{"id":"kitty-theme-manager","name":"Kitty Theme Manager","lane":"general ops","kind":"shell","entrypoint":"./scripts/kitty_theme_manager.sh","wrapper":"~/bin/codex-kitty-theme","description":"Manage Kitty themes with interactive selection, persistent logs, and tmux-aware refresh behavior.","platforms":["linux","macos"],"commands":["./scripts/kitty_theme_manager.sh","./scripts/kitty_theme_manager.sh --plain-menu","./scripts/kitty_theme_manager.sh --theme \"Cento Rose Pine\"","tail -n 80 ./logs/kitty-theme-manager/latest.log"],"outputs":["~/.config/kitty/themes/*.conf","~/.config/kitty/current-theme.conf"],"notes":["Writes per-run logs to logs/kitty-theme-manager/.","If run inside tmux, it reloads tmux config and refreshes the client.","Uses Kitty signal-based reloads unless KITTY_LISTEN_ON is available."]},{"id":"system-inventory","name":"System Inventory","lane":"general ops","kind":"shell","entrypoint":"./scripts/system_inventory.sh","description":"Capture a Markdown baseline of host, shell, tooling, and environment state.","platforms":["linux","macos"],"commands":["./scripts/system_inventory.sh","./scripts/system_inventory.sh --output ~/reports/system.md"],"outputs":["workspace/runs/system-inventory-*.md"]},{"id":"repo-snapshot","name":"Repo Snapshot","lane":"general ops","kind":"shell","entrypoint":"./scripts/repo_snapshot.sh","description":"Create a compact repo status report including tree, git status, diffstat, and recent commits.","platforms":["linux","macos"],"commands":["./scripts/repo_snapshot.sh --target .","./scripts/repo_snapshot.sh --target ~/projects/cento"],"outputs":["workspace/runs/repo-snapshot-*.md"]},{"id":"project-scaffold","name":"Project Scaffold","lane":"general ops","kind":"shell","entrypoint":"./scripts/project_scaffold.sh","description":"Scaffold a generic project with starter README, notes, scripts, data, and workspace folders.","platforms":["linux","macos"],"commands":["./scripts/project_scaffold.sh --path ~/projects/example-kit"],"outputs":["new project directory tree"]},{"id":"batch-exec","name":"Batch Exec","lane":"general ops","kind":"shell","entrypoint":"./scripts/batch_exec.sh","description":"Run one shell command across multiple directories with dry-run and git-only support.","platforms":["linux","macos"],"commands":["./scripts/batch_exec.sh --root ~/projects --pattern '*' --command 'git status --short'","./scripts/batch_exec.sh --root ~/projects --pattern '*' --git-only --dry-run --command 'pwd'"],"outputs":["stdout execution summary"]},{"id":"search-report","name":"Search Report","lane":"general ops","kind":"shell","entrypoint":"./scripts/search_report.sh","description":"Search a filesystem tree and write a Markdown report with matches and context.","platforms":["linux","macos"],"commands":["./scripts/search_report.sh --query TODO --root ~/projects/cento","./scripts/search_report.sh --query bluetooth --root ~/projects"],"outputs":["workspace/runs/search-report-*.md"]},{"id":"rd","name":"Restart Discord","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/restart_discord.sh","description":"Terminate and relaunch Discord through the available desktop launcher.","platforms":["linux"],"commands":["cento rd"],"outputs":["stderr status messages"],"notes":["Launches via discord, Discord, Flatpak com.discordapp.Discord, or Snap discord."]},{"id":"tool-index","name":"Tool Index Generator","lane":"general ops","kind":"python","entrypoint":"./scripts/tool_index.py","description":"Generate a Markdown tool index from the central registry.","platforms":["linux","macos"],"commands":["python3 ./scripts/tool_index.py --registry data/tools.json --output docs/tool-index.md"],"outputs":["docs/tool-index.md"]},{"id":"platform-report","name":"Platform Report","lane":"general ops","kind":"python","entrypoint":"./scripts/platform_report.py","description":"Report declared macOS and Linux support for registered cento tools and generate docs/platform-support.md.","platforms":["linux","macos"],"commands":["cento platforms","cento platforms macos","python3 ./scripts/platform_report.py --markdown --output docs/platform-support.md"],"outputs":["docs/platform-support.md"],"notes":["Reads data/tools.json as the source of truth."]},{"id":"quick-help-fzf","name":"Quick Help FZF","lane":"general ops","kind":"shell","entrypoint":"./scripts/quick_help_fzf.sh","description":"Cross-platform fzf command palette for cento built-ins, tools, and aliases.","platforms":["linux","macos"],"commands":["cento quick-help-fzf","cento quick-help-fzf --print"],"outputs":["selected command execution"],"notes":["Use this on macOS; quick-help remains the Linux rofi palette."]},{"id":"install-macos","name":"macOS Installer","lane":"setup","kind":"shell","entrypoint":"./scripts/install_macos.sh","description":"Install local macOS dependencies, wrappers, PATH block, and Zsh integration for cento.","platforms":["macos"],"commands":["./scripts/install_macos.sh"],"outputs":["~/bin/cento","~/bin/codex-bt-audio-doctor","~/bin/codex-kitty-theme","~/.config/cento/init.zsh"]},{"id":"install-linux","name":"Linux Installer","lane":"setup","kind":"shell","entrypoint":"./scripts/install_linux.sh","description":"Install local Linux dependencies, wrappers, PATH block, and Zsh integration for cento.","platforms":["linux"],"commands":["./scripts/install_linux.sh"],"outputs":["~/bin/cento","~/bin/codex-bt-audio-doctor","~/bin/codex-kitty-theme","~/.config/cento/init.zsh"]},{"id":"notify","name":"Cento Notify","lane":"agent ops","kind":"shell","entrypoint":"./scripts/notify.sh","description":"Send cluster notifications to configured ntfy targets such as iPhone and Apple Watch mirrored alerts.","platforms":["linux","macos"],"commands":["cento notify setup iphone TOPIC","cento notify status","cento notify iphone \"Cluster job finished\"","cento notify all \"Linux healed\"","cento notify test iphone"],"outputs":["~/.config/cento/notify.json","ntfy push notification"],"notes":["Stores private ntfy topics in the machine-local Cento config, not in the repo.","Apple Watch delivery uses normal iPhone notification mirroring for the ntfy app."]},{"id":"cluster","name":"Cento Cluster Control","lane":"agent ops","kind":"shell","entrypoint":"./scripts/cluster.sh","description":"Manage Cento node identity, cluster registry, colored status, remote execution, bridge healing, and read-only git drift checks.","platforms":["linux","macos"],"commands":["cento cluster init","cento cluster nodes","cento cluster status","cento cluster exec linux -- tmux ls","cento cluster exec macos -- cento gather-context --no-remote","cento cluster sync","cento cluster heal","cento cluster heal linux","cento cluster heartbeat iphone","cento cluster metric memory","cento cluster ask \"send me notification with total memory consumption on the cluster\""],"outputs":["~/.config/cento/node.json","~/.config/cento/cluster.json","stdout cluster status and command output"],"notes":["status is the health view.","sync is a read-only git drift report; it never writes to either node.","heal is the single repair path for bridge services and sockets.","Remote execution uses the existing OCI Unix-socket mesh."]},{"id":"gather-context","name":"Gather Context","lane":"agent ops","kind":"python","entrypoint":"./scripts/gather_context.py","description":"Gather AI-ready local and remote Cento context including platform support, repo state, command paths, MCP hints, and SSH connectivity.","platforms":["linux","macos"],"commands":["cento gather-context","cento gather-context --no-remote","cento gather-context --json","cento gather-context --output workspace/runs/cento-context.md"],"outputs":["stdout Markdown or JSON","workspace/runs/cento-context.md"],"notes":["Use this before cross-node work so agents can reason from current platform, repo, SSH, and tool availability facts."]},{"id":"network-tui","name":"Cento Network Monitor","lane":"agent ops","kind":"shell","entrypoint":"./scripts/network_tui.sh","description":"Cluster-focused Bubble Tea monitor for Cento nodes, connection state, activity state, tmux presence, VM mesh sockets, and companion-device reachability.","platforms":["linux","macos"],"commands":["cento network-tui","cento network-tui --no-remote","./scripts/network_tui.sh"],"outputs":["interactive terminal dashboard","plain health summary when stdout is not a TTY"],"notes":["Uses gather-context as the data source.","Auto-refreshes every 10 seconds; press r to refresh manually."]}]}; +const TOOLS = {"tools":[{"id":"cento-cli","name":"Cento CLI","lane":"general ops","kind":"shell","entrypoint":"./scripts/cento.sh","wrapper":"~/bin/cento","description":"Unified cento facade for built-ins, terminal docs browsing, tool dispatch, and user-defined aliases.","platforms":["linux","macos"],"commands":["cento help","cento interactive","cento docs","cento docs conf","cento docs --json","cento docs --path","cento tools","cento aliases","cento conf","cento conf --path","cento completion zsh","cento install all","cento install zsh","cento install tmux","cento run scan --query \"mcp\"","cento build --help","cento build init --task \"Fixture docs page patch\" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture","cento build check tests/fixtures/cento_build/manifest.valid.json","cento runtime check codex-fast","cento workset check tests/fixtures/cento_workset/workset.valid.json","cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke","cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke","cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke","cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff","cento build integrate tests/fixtures/cento_build/manifest.valid.json --bundle .cento/builds/build_fixture_docs_page_001/integration/patch_bundle.json --dry-run","cento platforms","cento platforms macos","cento platforms linux","cento platforms --markdown"],"outputs":["~/.config/cento/aliases.sh","~/.config/cento/init.zsh","~/.config/cento/tmux.conf"],"notes":["Canonical built-in docs live in data/cento-cli.json.","Use cento interactive for the Bubble Tea terminal browser of built-ins, tools, and aliases.","Use cento docs for the non-interactive JSON-backed docs path.","Combined aliases can chain multiple cento subcommands through bash -lc.","Use cento platforms to compare declared macOS and Linux support."],"doc_json":"data/cento-cli.json","subcommands":[{"name":"help","summary":"Show root cento CLI help.","usage":"cento help","flags":[{"name":"-h, --help","summary":"Show root CLI help without naming the `help` subcommand.","usage":"cento --help"}],"examples":["cento help","cento --help"]},{"name":"interactive","summary":"Open the terminal interactive browser for built-ins, tools, aliases, and docs.","usage":"cento interactive","flags":[],"examples":["cento interactive","cento interactive --section builtins","cento interactive --entry conf"]},{"name":"docs","summary":"Print cento CLI docs from the canonical JSON source.","usage":"cento docs [ENTRY] [--json|--path]","flags":[{"name":"--json","summary":"Print the raw JSON document.","usage":"cento docs --json"},{"name":"--path","summary":"Print the path to the canonical JSON document.","usage":"cento docs --path"}],"examples":["cento docs","cento docs conf","cento docs --json","cento docs --path"]},{"name":"tools","summary":"List registered cento tools from the tool registry.","usage":"cento tools","flags":[],"examples":["cento tools"]},{"name":"aliases","summary":"List configured user aliases.","usage":"cento aliases","flags":[],"examples":["cento aliases"]},{"name":"conf","summary":"Open or print the cento alias config file.","usage":"cento conf [--path]","flags":[{"name":"--path","summary":"Print the config path instead of opening it in an editor.","usage":"cento conf --path"}],"examples":["cento conf","cento conf --path"]},{"name":"completion","summary":"Print shell completion for a supported shell.","usage":"cento completion zsh","flags":[],"examples":["cento completion zsh"]},{"name":"install","summary":"Install cento shell prompt, completion, and tmux node markers.","usage":"cento install [all|zsh|tmux]","flags":[{"name":"all","summary":"Install Zsh completion/prompt integration and tmux node status integration.","usage":"cento install all"},{"name":"zsh","summary":"Install Zsh completion plus a right-side Cento node marker.","usage":"cento install zsh"},{"name":"tmux","summary":"Install tmux status markers that show the Cento node and keep sessions separate.","usage":"cento install tmux"}],"examples":["cento install","cento install all","cento install zsh","cento install tmux"]},{"name":"run","summary":"Run a registered tool by id, or create a fast/standard/thorough execution contract with optional one-local-builder patch collection for owned-path tasks.","usage":"cento run TOOL [args...] | cento run fast|standard|thorough --task TEXT [--write PATH] [--local-builder [RUNTIME] --apply]","flags":[],"examples":["cento run scan --query \"mcp\"","cento run crm docs","cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo","cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo --local-builder fixture --fixture-case valid --apply --validation smoke --commit none"]},{"name":"build","summary":"Own patch units: create manifest-owned local build packages, run one local worker, check artifacts, synthesize patch bundles, dry-run integrate, and apply accepted bundles.","usage":"cento build <init|check|prompt|worker|artifact|bundle|integrate|apply|receipt> [args...]","flags":[],"examples":["cento build init --task \"Fixture docs page patch\" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture","cento build check tests/fixtures/cento_build/manifest.valid.json","cento build worker run .cento/builds/<id>/manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180","cento build worker run .cento/builds/<id>/manifest.json --worker builder_1 --runtime-profile codex-fast --worktree","cento runtime check codex-fast","cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke","cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff","cento build integrate .cento/builds/<id>/manifest.json --bundle .cento/builds/<id>/workers/builder_1/patch_bundle.json --worktree --dry-run","cento build apply .cento/builds/<id>/manifest.json --bundle .cento/builds/<id>/workers/builder_1/patch_bundle.json --from-receipt .cento/builds/<id>/integration_receipt.json"]},{"name":"workset","summary":"Own parallel lease semantics with local N-worker worksets, exclusive write paths, structured API artifacts, dependency gates, and sequential integration.","usage":"cento workset <check|run|execute|materialize-artifact> [args...]","flags":[],"examples":["cento workset check tests/fixtures/cento_workset/workset.valid.json","cento workset check tests/fixtures/cento_workset/workset.overlap.json","cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke","cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke","cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke","cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke"]}]},{"id":"bridge","name":"OCI SSH Bridge","lane":"remote access","kind":"shell","entrypoint":"./scripts/bridge.sh","description":"Create a reverse SSH tunnel through the OCI VM so another machine can SSH back into this host through the VM relay.","platforms":["linux","macos"],"commands":["cento bridge start","cento bridge status","cento bridge stop","cento bridge restart","cento bridge foreground","cento bridge command","cento bridge mac-command","cento bridge docs","cento bridge check","cento bridge from-mac","cento bridge --from-mac","cento bridge from-mac -- 'cd \"$HOME/projects/cento\" && ./scripts/cento.sh platforms linux'","cento bridge expose-linux","cento bridge install-linux-service","cento bridge install-mac-service","cento bridge expose-mac","cento bridge to-linux","cento bridge to-mac","cento bridge mesh-status","cento bridge to-linux -- 'cd \"$HOME/projects/cento\" && ./scripts/cento.sh gather-context --no-remote | head -90'","cento bridge to-mac -- '/Users/anovik-air/bin/cento gather-context --no-remote | head -90'","cento bridge context-linux","cento bridge context-mac"],"outputs":["~/.local/state/cento/bridge.pid","~/.local/state/cento/bridge.log"],"notes":["Defaults to the OCI instance opc@129.213.17.199 from instance-20250511-1002.","Uses ~/.ssh/id_ed25519 as the default private key for that VM.","Requests a localhost reverse tunnel on the VM: 127.0.0.1:2222 -> this machine 127.0.0.1:22.","Some VM sshd configurations override remote-forward bind addresses; OCI ingress rules still control public reachability.","The Mac connects with ProxyJump through the VM instead of using a public relay port.","Use --public only after configuring VM sshd GatewayPorts, VM firewall, and OCI ingress rules.","Use cento bridge check to validate the local repo and Mac-through-VM SSH path.","`cento bridge from-mac` runs remote gather-context on the Linux node through the OCI ProxyJump path by default.","Secure mesh mode uses SSH remote Unix sockets on the VM, e.g. /tmp/cento-linux.sock and /tmp/cento-mac.sock, instead of public VM TCP listeners.","Use install-linux-service on the Linux node to keep /tmp/cento-linux.sock repaired automatically.","Use install-mac-service on the Mac node to keep /tmp/cento-mac.sock repaired automatically with launchd.","Use expose-linux on the Linux node and expose-mac on the Mac node, then use to-linux/to-mac for node-to-node commands.","expose-mac starts a user-level localhost sshd on the Mac, avoiding the need to enable system Remote Login.","to-linux and to-mac open interactive shells by default; use context-linux/context-mac for gather-context output."]},{"id":"daily","name":"Daily Execution Support","lane":"execution","kind":"shell","entrypoint":"./scripts/daily_tui.sh","description":"Bubble Tea execution cockpit for morning brief, midday recalibration, evening wrap-up, and local continuity.","platforms":["linux","macos"],"commands":["cento daily"],"outputs":["workspace/runs/daily/history.json"],"notes":["Mock brief generation is isolated behind a BriefGenerator interface for later LLM replacement.","The launcher builds a cached Bubble Tea binary from scripts/daily_tui.go."]},{"id":"tui","name":"Telegram TUI","lane":"communications","kind":"shell","entrypoint":"./scripts/telegram_tui.sh","description":"Bubble Tea Telegram TUI with cached Go launcher, local config, and planned CRM hooks.","platforms":["linux","macos"],"commands":["cento tui","cento tui status","cento tui config --path","cento tui docs","cento crm integration --provider telegram"],"outputs":["~/.config/cento/telegram.json","workspace/runs/telegram-tui/*.md","workspace/runs/crm-app/<profile>/integration-telegram.md"],"notes":["This tool follows the repo TUI standard in standards/tui.md.","The launcher builds a cached Bubble Tea binary from scripts/telegram_tui.go.","CRM integration remains a registered placeholder under cento crm integration."]},{"id":"crm","name":"CRM Module","lane":"career consulting","kind":"python","entrypoint":"./scripts/crm_module.py","description":"Embedded cento CRM with questionnaire bootstrap, career-intake dossiers, local JSON persistence, and a self-hosted no-build SPA.","platforms":["linux","macos"],"commands":["cento crm","cento crm questionnaire","cento crm init","cento crm intake init --person \"Ada Lovelace\"","cento crm intake add --person \"Ada Lovelace\" --kind resume --file ./resume.pdf","cento crm intake plan --person \"Ada Lovelace\"","cento crm serve --open","cento crm show","cento crm docs"],"outputs":["workspace/runs/crm-questionnaire/<profile>/answers.json","workspace/runs/crm-questionnaire/<profile>/summary.md","workspace/runs/crm-app/<profile>/state.json","workspace/runs/crm-app/latest.json","workspace/runs/career-intake/<person>/manifest.json","workspace/runs/career-intake/<person>/artifact-plan.md","workspace/runs/career-intake/<person>/prompts/*.md","workspace/runs/career-intake/<person>/artifacts/*.md"],"notes":["Run cento crm serve to host the local SPA through the cento CLI.","Run cento crm init to bootstrap app state from the saved questionnaire.","Run cento crm intake to collect raw candidate inputs and generate a Codex-ready artifact plan.","The CRM is a no-build local app backed by JSON persistence."]},{"id":"burp","name":"Burp Suite Community","lane":"security testing","kind":"shell","entrypoint":"./scripts/burp_suite_community.sh","description":"Download, set up, and control PortSwigger Burp Suite Community through cento wrappers.","platforms":["linux"],"commands":["cento burp download","cento burp download --type linux","cento burp setup","cento burp controller start --use-defaults","cento burp run -- --help","cento burp status","cento burp stop","cento burp docs"],"outputs":["~/.local/share/cento/burp/downloads/*","~/.local/share/cento/burp/current/burpsuite_community.jar","~/.local/bin/burp-community","~/.local/share/cento/burp/install.env","~/.local/share/cento/burp/burp.pid","~/.local/share/cento/burp/burp.log"],"notes":["Downloads use PortSwigger's official latest Community Edition endpoints.","The default setup path installs the official JAR and generates a local burp-community launcher.","Use download --type linux to fetch the official Linux installer for later manual or automated installer work.","Burp Suite is a GUI application; controller start runs it in the background and records a local PID."]},{"id":"mcp","name":"MCP Tooling","lane":"general ops","kind":"python","entrypoint":"./scripts/mcp_tooling.py","description":"Manage repo-root MCP config, env templates, validation, and tool-call docs.","platforms":["linux","macos"],"commands":["cento mcp doctor","cento mcp init --write-env","cento mcp docs","cento mcp paths"],"outputs":[".mcp.json",".env.mcp.example",".env.mcp","mcp/*.md"],"notes":["The canonical shared config lives at the repo root in .mcp.json.","Machine-local secrets should live in environment variables or .env.mcp.","This tool follows standards/mcp.md."]},{"id":"cento-mcp","name":"Cento MCP Server","lane":"agent ops","kind":"python","entrypoint":"./scripts/cento_mcp_server.py","description":"Local MCP stdio server that exposes safe Cento agent-work, story manifest, cluster, bridge, and context tools.","platforms":["linux","macos"],"commands":["python3 scripts/cento_mcp_server.py --list-tools","python3 scripts/cento_mcp_server.py --call-tool cento_agent_work_list --arguments '{}'","python3 scripts/cento_mcp_server.py --call-tool cento_context --arguments '{\"remote\":false}'","cento mcp doctor"],"outputs":["MCP tools over stdio","structured JSON command results"],"notes":["Configured as the `cento` server in .mcp.json.","The server exposes explicit write tools for agent-work mutations and story hub generation.","Set CENTO_MCP_READ_ONLY=1 to disable write tools.","Local paths are constrained to the Cento repo root."]},{"id":"scan","name":"Scan One Pager","lane":"general ops","kind":"python","entrypoint":"./scripts/scan_onepager.py","description":"Scan cento for a topic and generate an archived HTML one-pager with explanation and snippets.","platforms":["linux","macos"],"commands":["cento scan --query \"mcp\"","cento scan --query \"telegram\" --no-open","cento scan --query \"crm\" --case-sensitive","cento scan --query \"mcp\" --port 47890"],"outputs":["workspace/runs/scan-onepager/latest/index.html","workspace/runs/scan-onepager/latest/summary.json","workspace/runs/scan-onepager/archive/*","workspace/runs/scan-onepager/server.json"],"notes":["Each run archives the previous latest output before writing the new page.","The tool starts or reuses a local preview server on a high port and opens the browser by default.","See docs/scan-onepager.md for the command surface and output model."]},{"id":"bluetooth-audio-doctor","name":"Bluetooth Audio Doctor","lane":"general ops","kind":"python","entrypoint":"./scripts/bluetooth_audio_doctor.py","wrapper":"~/bin/codex-bt-audio-doctor","description":"Diagnose Bluetooth and Bluetooth-audio failures, generate detailed reports, and apply safe repair actions.","platforms":["linux"],"commands":["python3 ./scripts/bluetooth_audio_doctor.py \"Black Diamond\"","python3 ./scripts/bluetooth_audio_doctor.py \"Black Diamond\" --fix","python3 ./scripts/bluetooth_audio_doctor.py \"Black Diamond\" --fix --repair-pairing"],"outputs":["stdout Markdown report","~/bluetooth-audio-reports/*.md"],"notes":["The script is safe by default.","--repair-pairing removes the current Bluetooth bond and pairs again."]},{"id":"audio-quick-connect","name":"Audio Quick Connect","lane":"general ops","kind":"shell","entrypoint":"./scripts/audio_quick_connect.sh","description":"Quickly connect a paired Bluetooth audio device by name or address with a short retry path and per-run logs.","platforms":["linux"],"commands":["./scripts/audio_quick_connect.sh \"Black Diamond\"","./scripts/audio_quick_connect.sh \"Bose\"","cento audio-quick-connect \"Black Diamond\""],"outputs":["logs/audio-quick-connect/*.log"],"notes":["Matches paired devices by exact name, substring, or MAC address.","Verifies the target advertises Bluetooth audio capabilities before connecting.","Writes the latest run to logs/audio-quick-connect/latest.log."]},{"id":"dashboard","name":"Dashboard","lane":"general ops","kind":"python","entrypoint":"./scripts/dashboard_server.py","description":"Run a localhost web dashboard with current state, recent cento activity, aliases, tools, and repo progress.","platforms":["linux"],"commands":["./scripts/dashboard_server.py","./scripts/dashboard_server.py --open","./scripts/dashboard_server.py --theme industrial --open","./scripts/dashboard_server.py --host 127.0.0.1 --port 46268","cento dashboard"],"outputs":["logs/dashboard/*.log"],"notes":["Starts a local HTTP server on 127.0.0.1 by default.","Use --theme industrial for the Industrial OS dashboard skin.","Shows current theme, wallpaper, audio, displays, recent tool runs, aliases, tools, and git progress.","Use --open to launch it in your default browser."]},{"id":"preset","name":"Desktop Presets","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/preset.sh","description":"Apply managed Cento desktop presets such as the Industrial OS i3 theme and dashboard.","platforms":["linux"],"commands":["cento preset list","cento preset industrial-os","cento preset industrial-os --workspace","cento preset industrial-os --workspace --black-only","cento preset industrial-os --session","cento preset industrial-os --dashboard-only --open","cento dashboard --theme industrial --open"],"outputs":["~/.config/cento/preset.env","~/.config/cento/industrial-os/polybar/config.ini","~/.config/cento/industrial-os/rofi.rasi","~/.local/share/cento/industrial-os/wallpaper.png","~/.local/state/cento/industrial-os/dashboard.url","logs/industrial-os/*.log","logs/industrial-workspace/*.log"],"notes":["industrial-os writes a guarded block to ~/.config/i3/config so i3 reloads keep the preset active.","The preset applies the Cento Industrial OS Kitty theme, generated wallpaper, Polybar config, Rofi theme, and Picom config; the themed dashboard server stays on the explicit --dashboard-only path.","Mod+Shift+I runs --workspace and composes workspace 1 into the Discord, hero, terminal, Darth Lolipopus pet, cluster, activity, and actions tile layout with background images on every generated pane without starting the dashboard server.","Use --black-only or CENTO_INDUSTRIAL_BACKGROUND_MODE=black for plain black workspace pane backgrounds.","Mod+h/j/k/l uses the Industrial OS visual focus router on the cockpit and falls back to native i3 focus elsewhere.","--session reapplies runtime pieces without rewriting the i3 config and is intended for i3 startup."]},{"id":"industrial-pet","name":"Darth Lolipopus Pet Pane","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/industrial_pet_tui.sh","description":"Cute Sith Tamagotchi pane for Darth Lolipopus in the Industrial OS workspace.","platforms":["linux","macos"],"commands":["cento industrial-pet","cento industrial-pet --once --width 98 --height 24","cento industrial-pet --action nap","cento industrial-pet --image assets/industrial-os/darth-lolipopus.png","cento industrial-pet --portrait slot","cento industrial-pet --reset"],"outputs":["${XDG_STATE_HOME:-~/.local/state}/cento/industrial-os/darth-lolipopus.json","interactive terminal pet pane"],"docs":["docs/industrial-pet.md"],"notes":["State path, database path, and portrait image path are overrideable with --state, --database, and --image for deterministic tests.","Industrial OS launches this pane in the bottom-left tile that previously hosted the jobs dashboard.","The default portrait uses assets/industrial-os/darth-lolipopus.png, matching the rofi launcher side art.","Industrial OS uses assets/industrial-os/darth-lolipopus-pane.png as a high-resolution Kitty background and runs the TUI with --portrait slot to avoid terminal-cell pixelation.","Activities are Sith snack, duel practice, nap, cape compliment, helmet polish, and tiny mission."]},{"id":"quick-help","name":"Quick Help","lane":"general ops","kind":"shell","entrypoint":"./scripts/quick_help.sh","description":"Rofi-based searchable help palette for cento built-ins, tools, and aliases.","platforms":["linux"],"commands":["./scripts/quick_help.sh","./scripts/quick_help.sh --show","cento quick-help"],"outputs":["logs/quick-help/*.log"],"notes":["Uses rofi when available and follows your existing polybar rofi launcher theme when present.","Lets you search cento built-ins, registered tools, and aliases from one palette.","Can run the selected command or copy it to the clipboard."]},{"id":"display-layout-fix","name":"Display Layout Fix","lane":"general ops","kind":"shell","entrypoint":"./scripts/display_layout_fix.sh","description":"Detect two connected monitors, stack them vertically, and refresh wallpaper plus polybar.","platforms":["linux"],"commands":["./scripts/display_layout_fix.sh --show","./scripts/display_layout_fix.sh --save-defaults","./scripts/display_layout_fix.sh --top DP-4.8 --bottom HDMI-0 --save-defaults"],"outputs":["~/.config/cento/display.env","logs/display-layout-fix/*.log"],"notes":["Defaults to using the primary connected output as the top monitor.","Reapplies wallpaper and relaunches polybar after xrandr changes.","i3 startup now calls cento display-layout-fix instead of a hardcoded xrandr --right-of line."]},{"id":"i3reorg","name":"i3 Reorg","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/i3reorg.sh","description":"Move numeric i3 workspaces to the bottom monitor, apply the preferred app map, and optionally place the Abao/Tokyo study YouTube window on top workspace L2 fullscreen.","platforms":["linux"],"commands":["./scripts/i3reorg.sh","./scripts/i3reorg.sh --dry-run","./scripts/i3reorg.sh --bottom-output DP-4.8","./scripts/i3reorg.sh --study","cento i3reorg","cento i3reorg --study","cento i3reorg --focus 2"],"outputs":["i3 workspace moves"],"notes":["Detects the active output with the largest y-position as the bottom monitor and moves workspaces 1-5 there before moving windows.","Study mode targets https://www.youtube.com/watch?v=QYpDQxHfTPk and keeps the matching Firefox YouTube window on top workspace L2 fullscreen. In the i3 key layout, L2 is the left/A workspace and R2 is the right/D workspace.","Uses i3 criteria against common Firefox, terminal, Discord, and Telegram window classes.","The Linux-only guardrail is declared through this registry entry and enforced centrally by the cento dispatcher."]},{"id":"wallpaper-manager","name":"Wallpaper Manager","lane":"general ops","kind":"shell","entrypoint":"./scripts/wallpaper_manager.sh","description":"Choose, preview, apply, and persist desktop wallpapers for i3 and feh.","platforms":["linux"],"commands":["./scripts/wallpaper_manager.sh --choose","./scripts/wallpaper_manager.sh --set green_arctic.jpg","./scripts/wallpaper_manager.sh --apply-current","./scripts/wallpaper_manager.sh --list"],"outputs":["~/.config/cento/wallpaper.env","logs/wallpaper-manager/*.log"],"notes":["Uses the current i3 wallpaper library in ~/.config/kitty by default.","Interactive picker uses fzf and attempts Kitty-based preview when available.","i3 startup can call cento wallpaper-manager --apply-current to restore the saved wallpaper."]},{"id":"kitty-theme-manager","name":"Kitty Theme Manager","lane":"general ops","kind":"shell","entrypoint":"./scripts/kitty_theme_manager.sh","wrapper":"~/bin/codex-kitty-theme","description":"Manage Kitty themes with interactive selection, persistent logs, and tmux-aware refresh behavior.","platforms":["linux","macos"],"commands":["./scripts/kitty_theme_manager.sh","./scripts/kitty_theme_manager.sh --plain-menu","./scripts/kitty_theme_manager.sh --theme \"Cento Rose Pine\"","tail -n 80 ./logs/kitty-theme-manager/latest.log"],"outputs":["~/.config/kitty/themes/*.conf","~/.config/kitty/current-theme.conf"],"notes":["Writes per-run logs to logs/kitty-theme-manager/.","If run inside tmux, it reloads tmux config and refreshes the client.","Uses Kitty signal-based reloads unless KITTY_LISTEN_ON is available."]},{"id":"system-inventory","name":"System Inventory","lane":"general ops","kind":"shell","entrypoint":"./scripts/system_inventory.sh","description":"Capture a Markdown baseline of host, shell, tooling, and environment state.","platforms":["linux","macos"],"commands":["./scripts/system_inventory.sh","./scripts/system_inventory.sh --output ~/reports/system.md"],"outputs":["workspace/runs/system-inventory-*.md"]},{"id":"repo-snapshot","name":"Repo Snapshot","lane":"general ops","kind":"shell","entrypoint":"./scripts/repo_snapshot.sh","description":"Create a compact repo status report including tree, git status, diffstat, and recent commits.","platforms":["linux","macos"],"commands":["./scripts/repo_snapshot.sh --target .","./scripts/repo_snapshot.sh --target ~/projects/cento"],"outputs":["workspace/runs/repo-snapshot-*.md"]},{"id":"project-scaffold","name":"Project Scaffold","lane":"general ops","kind":"shell","entrypoint":"./scripts/project_scaffold.sh","description":"Scaffold a generic project with starter README, notes, scripts, data, and workspace folders.","platforms":["linux","macos"],"commands":["./scripts/project_scaffold.sh --path ~/projects/example-kit"],"outputs":["new project directory tree"]},{"id":"batch-exec","name":"Batch Exec","lane":"general ops","kind":"shell","entrypoint":"./scripts/batch_exec.sh","description":"Run one shell command across multiple directories with dry-run and git-only support.","platforms":["linux","macos"],"commands":["./scripts/batch_exec.sh --root ~/projects --pattern '*' --command 'git status --short'","./scripts/batch_exec.sh --root ~/projects --pattern '*' --git-only --dry-run --command 'pwd'"],"outputs":["stdout execution summary"]},{"id":"temp","name":"Cento Temporary Commands","lane":"ops","kind":"shell","entrypoint":"./scripts/cento_temp.sh","description":"One-command operator clipboard bridge that copies the fixed Markdown reference configured in scripts/cento_temp.sh through pbcopy.","platforms":["linux","macos"],"commands":["cento temp run"],"outputs":["clipboard","workspace/runs/temp/cento-ultimate-ai-reference.md"],"notes":["Only `cento temp run` is supported. Do not add ids, flags, list/show/add/remove, cross-node targets, secret prompts, or generated temp command registries.","To change what gets copied, edit only the `COPY_FILE` line in scripts/cento_temp.sh.","The wrapper validates the fixed Markdown file and runs `pbcopy < \"$COPY_FILE\"`; clipboard transport belongs in the local pbcopy shim, not in cento temp."]},{"id":"search-report","name":"Search Report","lane":"general ops","kind":"shell","entrypoint":"./scripts/search_report.sh","description":"Search a filesystem tree and write a Markdown report with matches and context.","platforms":["linux","macos"],"commands":["./scripts/search_report.sh --query TODO --root ~/projects/cento","./scripts/search_report.sh --query bluetooth --root ~/projects"],"outputs":["workspace/runs/search-report-*.md"]},{"id":"discord","name":"Discord Control","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/restart_discord.sh","description":"Update, rerun, and inspect Discord through a Cento-native Linux desktop control command.","platforms":["linux"],"commands":["cento discord status","cento discord update","cento discord update --rerun","cento discord rerun"],"outputs":["~/.local/opt/Discord","~/.config/discord/app-*","workspace/runs/discord/rerun-*.log","stderr status messages"],"notes":["cento discord update installs the latest official Linux tarball into the user profile without sudo.","cento discord rerun prefers the user-local install, bootstraps the Discord host without zenity, then falls back to system, Flatpak, or Snap launchers.","Use this when the packaged Discord host exits with a manual update requirement."]},{"id":"rd","name":"Restart Discord","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/restart_discord.sh","description":"Compatibility shortcut for `cento discord rerun`.","platforms":["linux"],"commands":["cento rd","cento rd rerun"],"outputs":["workspace/runs/discord/rerun-*.log","stderr status messages"],"notes":["Launches via the user-local Discord install, system discord, Discord, Flatpak com.discordapp.Discord, or Snap discord.","Prefer `cento discord update` and `cento discord rerun` for new automation."]},{"id":"tool-index","name":"Tool Index Generator","lane":"general ops","kind":"python","entrypoint":"./scripts/tool_index.py","description":"Generate a Markdown tool index from the central registry.","platforms":["linux","macos"],"commands":["python3 ./scripts/tool_index.py --registry data/tools.json --output docs/tool-index.md"],"outputs":["docs/tool-index.md"]},{"id":"platform-report","name":"Platform Report","lane":"general ops","kind":"python","entrypoint":"./scripts/platform_report.py","description":"Report declared macOS and Linux support for registered cento tools and generate docs/platform-support.md.","platforms":["linux","macos"],"commands":["cento platforms","cento platforms macos","python3 ./scripts/platform_report.py --markdown --output docs/platform-support.md"],"outputs":["docs/platform-support.md"],"notes":["Reads data/tools.json as the source of truth."]},{"id":"quick-help-fzf","name":"Quick Help FZF","lane":"general ops","kind":"shell","entrypoint":"./scripts/quick_help_fzf.sh","description":"Cross-platform fzf command palette for cento built-ins, tools, and aliases.","platforms":["linux","macos"],"commands":["cento quick-help-fzf","cento quick-help-fzf --print"],"outputs":["selected command execution"],"notes":["Use this on macOS; quick-help remains the Linux rofi palette."]},{"id":"install-macos","name":"macOS Installer","lane":"setup","kind":"shell","entrypoint":"./scripts/install_macos.sh","description":"Install local macOS dependencies, wrappers, PATH block, and Zsh integration for cento.","platforms":["macos"],"commands":["./scripts/install_macos.sh"],"outputs":["~/bin/cento","~/bin/codex-bt-audio-doctor","~/bin/codex-kitty-theme","~/.config/cento/init.zsh"]},{"id":"install-linux","name":"Linux Installer","lane":"setup","kind":"shell","entrypoint":"./scripts/install_linux.sh","description":"Install local Linux dependencies, wrappers, PATH block, and Zsh integration for cento.","platforms":["linux"],"commands":["./scripts/install_linux.sh"],"outputs":["~/bin/cento","~/bin/codex-bt-audio-doctor","~/bin/codex-kitty-theme","~/.config/cento/init.zsh"]},{"id":"notify","name":"Cento Notify","lane":"agent ops","kind":"shell","entrypoint":"./scripts/notify.sh","description":"Send cluster notifications to configured ntfy targets such as iPhone and Apple Watch mirrored alerts.","platforms":["linux","macos"],"commands":["cento notify setup iphone TOPIC","cento notify status","cento notify iphone \"Cluster job finished\"","cento notify all \"Linux healed\"","cento notify test iphone"],"outputs":["~/.config/cento/notify.json","ntfy push notification"],"notes":["Stores private ntfy topics in the machine-local Cento config, not in the repo.","Apple Watch delivery uses normal iPhone notification mirroring for the ntfy app."]},{"id":"cluster","name":"Cento Cluster Control","lane":"agent ops","kind":"shell","entrypoint":"./scripts/cluster.sh","description":"Manage Cento node identity, cluster registry, colored status, remote execution, bridge healing, and read-only git drift checks.","platforms":["linux","macos"],"commands":["cento cluster init","cento cluster nodes","cento cluster status","cento cluster exec linux -- tmux ls","cento cluster exec macos -- cento gather-context --no-remote","CENTO_IPHONE_URL=http://iphone-cento.local:47919 cento cluster exec iphone -- health","cento cluster sync","cento cluster heal","cento cluster heal linux","cento cluster heartbeat iphone","cento cluster metric memory","cento cluster ask \"send me notification with total memory consumption on the cluster\"","cento cluster activity linux","cento cluster activity --json linux","cento cluster exec linux -- 'cd /home/alice/projects/cento && pwd'","scripts/cluster_health_e2e.sh","cento cluster companion-setup iphone"],"outputs":["~/.config/cento/node.json","~/.config/cento/cluster.json","stdout cluster status and command output","~/.config/cento/companions/iphone-ish-setup.sh","workspace/runs/agent-work/30/summary.md","workspace/runs/agent-work/30/logs/*.log"],"notes":["status is the health view.","sync is a read-only git drift report; it never writes to either node.","heal is the single repair path for bridge services and sockets.","Remote execution uses the existing OCI Unix-socket mesh.","The iPhone remains a companion node, but can expose an authenticated CentoMobile app control endpoint for health/status via cluster exec iphone.","cluster_health_e2e.sh validates Mac-to-Linux execution paths used by agents before relying on the cluster.","companion-setup prints a POSIX iPhone/iSH installer that preserves natural-language text by shell-quoting each SSH argument.","Remote execution uses bash -lc on the target node, supports quoted shell commands and argv-style commands, repairs the Linux socket once when stale, and falls back to alice@alisapad.local when the LAN route is available."]},{"id":"gather-context","name":"Gather Context","lane":"agent ops","kind":"python","entrypoint":"./scripts/gather_context.py","description":"Gather AI-ready local and remote Cento context including platform support, repo state, command paths, MCP hints, and SSH connectivity.","platforms":["linux","macos"],"commands":["cento gather-context","cento gather-context --no-remote","cento gather-context --json","cento gather-context --output workspace/runs/cento-context.md"],"outputs":["stdout Markdown or JSON","workspace/runs/cento-context.md"],"notes":["Use this before cross-node work so agents can reason from current platform, repo, SSH, and tool availability facts."]},{"id":"mozilla-vpn","name":"Mozilla VPN Pane","lane":"desktop ops","kind":"shell","entrypoint":"./scripts/mozilla_vpn_tui.sh","description":"Native Mozilla VPN control pane for the Industrial OS workspace, with status, UI launch, login, activate, and deactivate actions.","platforms":["linux"],"commands":["cento mozilla-vpn","cento mozilla-vpn --once","cento mozilla-vpn status","cento mozilla-vpn countries","cento mozilla-vpn select COUNTRY","cento mozilla-vpn ui","cento mozilla-vpn login","cento mozilla-vpn activate","cento mozilla-vpn deactivate"],"outputs":["interactive terminal control pane","mozillavpn native CLI/UI actions"],"notes":["The pane calls the installed mozillavpn binary directly and does not use a browser dashboard.","After login, j/k moves through loaded countries and c or Enter selects the first server hostname for that country.","Industrial OS uses this tool in the bottom-right tile that previously hosted quick actions."]},{"id":"network-tui","name":"Cento Network Monitor","lane":"agent ops","kind":"shell","entrypoint":"./scripts/network_tui.sh","description":"Cluster-focused Bubble Tea monitor for Cento nodes, connection state, activity state, tmux presence, VM mesh sockets, and companion-device reachability.","platforms":["linux","macos"],"commands":["cento network-tui","cento network-tui --no-remote","./scripts/network_tui.sh"],"outputs":["interactive terminal dashboard","plain health summary when stdout is not a TTY"],"notes":["Uses gather-context as the data source.","Auto-refreshes every 10 seconds; press r to refresh manually."]},{"id":"agent-work","name":"Agent Work Tracker","lane":"agent ops","kind":"python","entrypoint":"./scripts/agent_work.py","description":"Lifecycle and governance substrate for Taskstream-backed Cento work: story/validation manifests, prompt handoff, dispatch/run ledgers, and review across the Mac/Linux cluster.","platforms":["linux","macos"],"commands":["cento agent-work bootstrap","cento agent-work create --title \"Fix dashboard\" --node linux --agent codex","cento agent-work split --title \"Improve mission control\" --nodes linux,macos --task \"Backend status\" --task \"Mac tile view\"","cento agent-work list","cento agent-work show 123","cento agent-work claim 123 --node linux --agent codex","cento agent-work update 123 --status review --note \"implemented and tested\"","cento agent-work prompt 123","cento agent-work dispatch 123 --node linux --dry-run","cento agent-pool-kick --dry-run","cento agent-pool-kick --max-launch 2 --runtime codex --model gpt-5.3-codex-spark","cento agent-work runs","cento agent-work runs --json --active","cento agent-work run-status RUN_ID --json"],"outputs":["docs/agent-work.html","docs/agent-run-ledger.md","Taskstream project cento-agent-work","workspace/runs/agent-runs/<run-id>/run.json","workspace/runs/agent-work/<run-id>/prompt.md","workspace/runs/agent-work/<run-id>/dispatch.json","workspace/runs/agent-work/<run-id>/codex.log"],"notes":["The web app shell is the Cento Console with top-level Taskstream, Cluster, Consulting, and Docs sections.","Taskstream is the main tasking backend used by agent-work, the Taskstream section, and cluster dispatch.","Agent Work story.json and validation.json are the preferred human-visible task contract for Patch Swarm/Factory pilots.","Use split to create one package with node-assigned work items, then dispatch or hand the generated prompt to an agent.","Use agent-pool-kick to keep cheap Spark/Codex workers busy; it is plan-only with --dry-run and launches workers when --dry-run is omitted.","Statuses are Queued, Running, Review, Blocked, and Done."]},{"id":"compute-policy","name":"Compute Policy","lane":"agent ops","kind":"python","entrypoint":"./scripts/compute_policy.py","description":"Manage provider-share policy for Codex, Claude Code, and metered OpenAI API use, then sync Agent Work runtime weights.","platforms":["linux","macos"],"commands":["cento compute-policy show","cento compute-policy show --json","cento compute-policy preset codex-first --json","cento compute-policy preset agent-preferred --json","cento compute-policy set --codex 85 --claude 15 --openai-api 0 --json","cento compute-policy apply --json"],"outputs":[".cento/compute-policy.json","data/agent-runtimes.json"],"notes":["Use this when you want to spend agent subscription or limit first and avoid metered API calls where agent dispatch can do the work.","When Codex/Claude weekly utilization is above 30%, prefer agent lanes for roughly 70-80% of eligible non-API-only work.","Codex and Claude shares become Agent Work weighted runtime values.","OpenAI API share is tracked for policy and analysis; explicit api-openai commands still require explicit operator/runtime selection.","Run `cento agent-work runtimes --sample 100 --json` after applying a policy to inspect the actual weighted route."]},{"id":"agent-pool-kick","name":"Agent Pool Kicker","lane":"agent ops","kind":"python","entrypoint":"./scripts/agent_pool_kick.py","description":"Dry-run-first bounded worker-pool planner and launcher for builder, validator, small-task, and coordinator lanes without unbounded dispatch.","platforms":["linux","macos"],"commands":["cento agent-pool-kick --dry-run","cento agent-pool-kick --max-launch 3 --dry-run","cento agent-pool-kick --repair-missing-manifests --repair-apply --repair-lanes all --max-launch 0 --dry-run","cento agent-pool-kick --package claude-chores --runtime claude-code --model claude-sonnet-4-6 --max-launch 2","cento agent-pool-kick --max-launch 3 --model gpt-5.3-codex-spark","cento agent-pool-kick --builder-target 2 --validator-target 2 --small-target 1 --coordinator-target 1","python3 scripts/agent_pool_kick.py --dry-run"],"outputs":["~/.local/state/cento/agent-pool-kick-latest.json","stdout JSON launch summary","Taskstream issue state and agent run ledgers through agent-work dispatch"],"notes":["Dry-run first before launching workers.","Use as the worker runtime planning surface after real queued work exists; do not create a duplicate Patch Swarm worker pool.","Use --repair-missing-manifests with --repair-lanes all to restore canonical story/validation manifests while keeping dispatch preflight enabled.","Defaults to the weighted runtime policy unless CENTO_AGENT_RUNTIME or --runtime overrides it.","Use --package to constrain dispatch to one Taskstream package before running cron or automation.","Defaults to the cheap Spark/Codex model unless CENTO_POOL_CODEX_MODEL, CENTO_POOL_CLAUDE_MODEL, or --model overrides it.","Uses current agent-work list and runs state to avoid dispatching issues that already have active runs.","Designed for a small cheap worker pool; use max-launch and target flags as guardrails."]},{"id":"claude-chores","name":"Claude Code Chores","lane":"agent ops","kind":"python","entrypoint":"./scripts/claude_chores.py","description":"Discover, document, schedule, and launch bounded Claude Code maintenance chores for Cento without metered OpenAI API spend.","platforms":["linux","macos"],"commands":["cento claude-chores plan --scope broad-repo --json","cento claude-chores run --scope broad-repo --chore-limit 2 --max-launch 2 --runtime claude-code --model claude-sonnet-4-6 --json","cento claude-chores run --scope broad-repo --chore-limit 2 --max-launch 2 --dry-run --json","cento claude-chores status --json","cento claude-chores install-cron --interval-minutes 30 --json","cento claude-chores uninstall-cron --json"],"outputs":["docs/claude-code-chores.md","workspace/runs/claude-chores/<timestamp>/candidate_chores.json","workspace/runs/claude-chores/<timestamp>/created_issues.json","workspace/runs/claude-chores/<timestamp>/dispatch_summary.json","workspace/runs/claude-chores/<timestamp>/claude-code-chores.md","workspace/runs/claude-chores/latest/status.json","~/.local/state/cento/claude-chores.log"],"notes":["Default policy is controlled saturation: every 30 minutes, create at most two chores and launch at most two Claude Code workers.","The worker pool is constrained to the claude-chores package so cron does not launch unrelated queued work.","When Codex/Claude utilization is above 30%, prefer agent lanes for roughly 70-80% of eligible non-API-only work.","The loop uses Claude Code subscription capacity and does not route chores through metered OpenAI API workers.","Use --crontab-file in tests or dry runs so the real user crontab is not modified."]},{"id":"walk-autopilot","name":"Walk Autopilot","lane":"agent ops","kind":"python","entrypoint":"./scripts/walk_autopilot.py","description":"Append-only follow-up coordinator for bounded Factory, spend-ledger, Hard ProReq, image fallback, agent-work hygiene, and worker-pool loops.","platforms":["linux","macos"],"commands":["cento walk-autopilot run --loops 12 --cadence-seconds 1200 --soft-cap-usd 12 --hard-cap-usd 20","cento walk-autopilot start-tmux --loops 12 --cadence-seconds 1200 --hard-cap-usd 20 --allow-live-api --dashboard-total-spend-usd 0 --notify-target iphone","cento walk-autopilot run --loops 1 --cadence-seconds 0","cento walk-autopilot start-tmux --loops 12 --cadence-seconds 1200 --notify-target iphone","cento walk-autopilot status","cento walk-autopilot review-unblock run --mode report --json","cento walk-autopilot review-unblock run --mode aggressive --json","cento walk-autopilot review-unblock status --json","cento walk-autopilot run --live-workers --review-unblock-mode aggressive","cento walk-autopilot patch-swarm run --candidate-target 100 --max-parallel-agents 5 --json","cento walk-autopilot patch-swarm status --json","cento walk-autopilot routing run --json","cento walk-autopilot routing status --json","cento walk-autopilot routing install-cron --every-hours 4 --json","cento walk-autopilot routing uninstall-cron --json","cento walk-autopilot factory-scale start --duration-hours 6 --proreq-executions 30 --min-proreq-calls 100 --patch-swarm --json","cento walk-autopilot factory-scale start-day --target-proreq-calls 3000 --max-proreq-calls 10000 --duration-hours 12 --batch-size 5 --json","cento walk-autopilot factory-scale preflight --run-id RUN_ID --json","cento walk-autopilot factory-scale advance --run-id RUN_ID --promotion-limit 25 --json","cento walk-autopilot factory-scale promote --run-id RUN_ID --limit 100 --factory-run workspace/runs/factory/factory-scale-promotion-RUN_ID --json","cento walk-autopilot factory-scale tick --run-id RUN_ID --batch-size 5 --json","cento walk-autopilot factory-scale status --run-id RUN_ID --json","cento walk-autopilot factory-scale install-cron --run-id RUN_ID --duration-hours 6 --json","cento walk-autopilot factory-scale uninstall-cron --json"],"outputs":["workspace/runs/walk-autopilot/<run-id>/metrics.jsonl","workspace/runs/walk-autopilot/<run-id>/spend-ledger.jsonl","workspace/runs/walk-autopilot/<run-id>/notes.md","workspace/runs/walk-autopilot/<run-id>/loops/loop-0001.md","workspace/runs/walk-autopilot/<run-id>/incidents/<incident-id>/incident.json","workspace/runs/walk-autopilot/<run-id>/handoff.md","workspace/runs/walk-autopilot/<run-id>/review-unblock/loop-0001/decision.json","workspace/runs/walk-autopilot/<run-id>/review-unblock/loop-0001/decision_report.md","workspace/runs/walk-autopilot/<run-id>/review-unblock/loop-0001/actions.jsonl","workspace/runs/walk-autopilot/review-unblock/<run-id>/snapshot.json","workspace/runs/walk-autopilot/review-unblock/<run-id>/decision.json","workspace/runs/walk-autopilot/review-unblock/<run-id>/decision_report.md","workspace/runs/walk-autopilot/review-unblock/latest/","workspace/runs/walk-autopilot/routing-native/<run-id>/raw_counts.json","workspace/runs/walk-autopilot/routing-native/<run-id>/decision.json","workspace/runs/walk-autopilot/routing-native/<run-id>/decision_report.md","workspace/runs/walk-autopilot/routing-native/<run-id>/agent_work_request.json","workspace/runs/walk-autopilot/routing-native/<run-id>/next_iteration.md","workspace/runs/walk-autopilot/routing-native/latest/","workspace/runs/walk-autopilot/factory-scale-<timestamp>/roadmap.md","workspace/runs/walk-autopilot/factory-scale-<timestamp>/config.json","workspace/runs/walk-autopilot/factory-scale-<timestamp>/events.jsonl","workspace/runs/walk-autopilot/factory-scale-<timestamp>/thoughts.jsonl","workspace/runs/walk-autopilot/factory-scale-<timestamp>/proreq-light-calls.jsonl","workspace/runs/walk-autopilot/factory-scale-<timestamp>/metrics.jsonl","workspace/runs/walk-autopilot/factory-scale-<timestamp>/spend-ledger.jsonl","workspace/runs/walk-autopilot/factory-scale-<timestamp>/handoff.md","workspace/runs/walk-autopilot/factory-scale-<timestamp>/cron.md","workspace/runs/walk-autopilot/factory-scale-<timestamp>/proreq-executions/exec-001/","workspace/runs/walk-autopilot/factory-scale-<timestamp>/patch-swarm/milestone-01/","workspace/runs/walk-autopilot/factory-scale-<timestamp>/advance/no-overlap-preflight.json","workspace/runs/walk-autopilot/factory-scale-<timestamp>/advance/live-api-guard.json","workspace/runs/walk-autopilot/factory-scale-<timestamp>/advance/candidate-matrix.json","workspace/runs/walk-autopilot/factory-scale-<timestamp>/advance/safe-integrator-promotion-plan.json","workspace/runs/walk-autopilot/factory-scale-<timestamp>/advance/factory-promotion-<factory-run>.json","workspace/runs/walk-autopilot/factory-scale-<timestamp>/advance/morning-report.md"],"docs":["docs/ai-review-unblock-autopilot.md","docs/ai-routing-nativeness-loop.md","docs/agent-work-live-dispatch-incident.md","docs/factory-1000-patch-swarm-roadmap.md","docs/walk-autopilot-spend-cap-incident.md"],"notes":["Each loop writes one Markdown summary with required AI handoff sections.","Each loop appends one metrics record and one spend summary record.","Factory dry-runs are recorded separately from explicit Pro/image/API spend.","Live worker launch failures are treated as incidents with bundled evidence, manifest repair, bounded retry, and recovery-plan artifacts when needed.","Live worker and live API behavior require explicit flags and remain bounded by soft/hard spend caps.","Live API lanes require an OpenAI dashboard total snapshot via --dashboard-total-spend-usd or CENTO_OPENAI_DASHBOARD_TOTAL_SPEND_USD; the hard cap applies to dashboard total spend, not only the local run ledger.","The Review/Unblock stage scans Review, Blocked, Validating, and stale run states each loop and chooses close, validate, requeue, repair-task, archive, or operator escalation actions.","Review/Unblock defaults to report mode unless --live-workers is enabled or --review-unblock-mode aggressive is passed.","Review/Unblock never closes Review items directly; it routes closures through agent-work review-drain, which requires validation pass plus evidence.","The routing nativeness subcommands run a lightweight counts-only observability loop on a four-hour cron cadence.","Routing cron writes reports and creates or updates one bounded Agent Work task for actionable changes; it does not implement code from cron.","Routing artifacts mirror to workspace/runs/walk-autopilot/routing-native/latest/.","Factory scale final test subcommands initialize a six-hour, 30-execution ProReq-light ledger run and derive status from append-only JSONL records.","Factory scale day mode derives ProReq-light executions from a target command-call count, defaults to 3,000 expected calls, enforces a 10,000-call ceiling, and advances in guarded batch ticks.","Factory scale preflight detects existing cron, run status, and active factory-scale/proreq/patch-swarm processes before start or advance creates any new work.","Factory scale advance reuses a completed 1,000-candidate run, indexes candidate receipts, writes a Safe Integrator promotion plan, and keeps live OpenAI/API disabled unless dashboard spend and rate-limit gates pass.","Factory scale promote turns advance promotion-plan entries into exclusive-path Factory patch bundles, apply plans, and parallel validation-fanout receipts; optional apply stays behind Factory/Safe Integrator worktree gates.","Factory scale cron uses the marked CENTO FACTORY SCALE FINAL TEST block, configurable cadence, flock, batch-size ticks, and a deadline check.","Every third factory-scale ProReq-light execution runs Patch Swarm fixture e2e for 100 candidate receipts and a Safe Integrator handoff."]},{"id":"agent-work-hygiene","name":"Agent Work Hygiene","lane":"agent ops","kind":"shell","entrypoint":"./scripts/agent_work_hygiene.sh","description":"Collect a point-in-time reconciliation report of agent run ledgers, tmux sessions, and Codex/Claude processes.","platforms":["linux","macos"],"commands":["cento agent-work-hygiene","cento agent-work-hygiene --issue 94","cento agent-work-hygiene --out-dir workspace/runs/agent-work/reconciliation","./scripts/agent_work_hygiene.sh"],"outputs":["workspace/runs/agent-work/reconciliation/hygiene-*/hygiene-report.md","workspace/runs/agent-work/reconciliation/hygiene-*/agent-work-runs.json","workspace/runs/agent-work/reconciliation/hygiene-*/tmux-sessions.txt","workspace/runs/agent-work/reconciliation/hygiene-*/process-probe.txt"],"notes":["Use before dispatching more workers when stale run records or blocked pool state are confusing capacity.","The report is evidence-first and does not mutate source code.","Pass --issue to scope reconciliation to one tracked run family."]},{"id":"agent-processes","name":"Agent Processes Dashboard","lane":"agent ops","kind":"shell","entrypoint":"./scripts/agent_processes_tui.sh","description":"Read-only process and worker visibility for cluster-wide managed/manual agent sessions, stale/risk indicators, and queue pressure.","platforms":["linux","macos"],"commands":["cento agent-processes","cento agent-processes --once","./scripts/agent_processes_tui.sh","./scripts/agent_processes_tui.sh --once"],"outputs":["interactive terminal dashboard","plain dashboard text when stdout is not a TTY"],"notes":["Data comes from `python3 scripts/agent_work.py runs --json --active` and `python3 scripts/agent_work.py list --json`.","Use this for Patch Swarm runtime visibility before adding a new worker dashboard or process state model.","Attempts `python3 scripts/agent_manager.py scan --json` for risk/stale/manual counts when available.","If manager scan is unavailable, the dashboard remains usable from runs/list data alone.","Press r to refresh, q/Ctrl-C to quit.","Use --once for CI and non-interactive output."]},{"id":"incident","name":"Cento Incident Response","lane":"agent ops","kind":"python","entrypoint":"./scripts/incident_response.py","description":"Bounded incident checks for Cento control-plane failures, with guarded SEV2 agent-work escalation for iPhone ce ingress failures.","platforms":["macos"],"commands":["cento incident check iphone-ce","cento incident check iphone-ce --json --no-create","cento incident status","cento incident install iphone-ce --interval 300 --dry-run","cento incident install iphone-ce --interval 300","cento incident uninstall iphone-ce","cento incident docs"],"outputs":["~/.local/state/cento/incidents.json","~/.local/state/cento/incident-response.log","~/Library/LaunchAgents/com.cento.incident.iphone-ce.plist","Taskstream SEV2 agent-work issue"],"notes":["The iphone-ce check is bounded to local heartbeat/request-spool reads plus timed agent-work calls; it should not hang.","Guardrails: one active issue per incident key, six-hour cooldown, one create per day by default, and a lock file for concurrent checks.","Use --no-create for dashboards or dry probes; use --force only for manual operator override."]},{"id":"opencode","name":"opencode","lane":"ai tools","kind":"shell","entrypoint":"./scripts/opencode.sh","wrapper":"~/bin/opencode","description":"Thin wrapper around opencode (Alisa-Novik fork of sst/opencode) \u2014 an open-source AI coding agent TUI.","platforms":["linux","macos"],"commands":["cento opencode","cento opencode --version","cento opencode --help","cento opencode fork-status"],"notes":["Binary installed via npm (opencode-ai). Fork source lives at ~/projects/opencode.","Fork: https://github.com/Alisa-Novik/opencode \u2014 based on sst/opencode.","Set OPENCODE_FORK_DIR to override the default fork path (~/projects/opencode).","All arguments after 'cento opencode' are forwarded to the opencode binary unchanged."]},{"id":"mobile","name":"Cento Mobile","lane":"mobile ops","kind":"shell","entrypoint":"./scripts/mobile.sh","description":"Native iOS/PWA mobile helper commands, including repeatable iOS e2e validation against the local mobile gateway.","platforms":["macos"],"commands":["cento mobile e2e","CENTO_IOS_E2E_PHYSICAL=false cento mobile e2e","CENTO_MOBILE_TOKEN=\"$(cento mobile token-from-linux)\" cento mobile e2e","cento mobile token-from-linux","cento mobile watch-status","cento mobile docs"],"outputs":["workspace/runs/agent-work/26/summary.md","workspace/runs/agent-work/26/screenshots/native-dashboard-simulator-e2e.png","workspace/runs/agent-work/26/logs/device-launch.json","workspace/runs/agent-work/22/devices/devicectl-list.json","workspace/runs/agent-work/22/devices/xctrace-devices.txt"],"notes":["The e2e harness validates gateway health, authenticated dashboard decoding, simulator build/install/launch/screenshot, and optional physical iPhone build/install/launch.","watch-status reports physical Apple Watch visibility, Developer Mode/DDI readiness, watch simulator inventory, and active simulator pairing.","token-from-linux reads the existing Linux gateway token over the Cento bridge; do not paste the token into tracked files.","devicectl launch JSON is redacted after physical launches because it records launch environment."]},{"id":"demo-evidence","name":"Demo Evidence Recorder","lane":"agent ops","kind":"python","entrypoint":"./scripts/demo_evidence.py","description":"Operator evidence utility for short 10-30 second desktop demo videos after real Factory, Codex worker, or validation flows exist.","platforms":["linux","macos"],"commands":["cento demo-evidence record --title \"Factory UI walkthrough\" --duration 15","cento demo-evidence record --factory-run workspace/runs/factory/<run> --task <task-id> --worker <worker-id> --duration 15 --notes \"Shows accepted flow\"","cento demo-evidence record --duration 10 --recorder synthetic --out workspace/runs/demo-evidence/smoke --json","cento demo-evidence record --duration 15 --dry-run --json","cento demo-evidence verify workspace/runs/demo-evidence/<run>","cento demo-evidence status workspace/runs/demo-evidence/<run> --json"],"outputs":["workspace/runs/demo-evidence/<run>/demo.mp4","workspace/runs/demo-evidence/<run>/receipt.json","workspace/runs/demo-evidence/<run>/summary.md","workspace/runs/factory/<run>/tasks/<task-id>/evidence/demo-*/demo.mp4","workspace/runs/factory/<run>/tasks/<task-id>/evidence/demo-*/receipt.json"],"notes":["Use this after a Builder or Codex worker has a visible product flow to prove, especially before Factory validation or release handoff.","Demo evidence is proof for a real flow, not a substitute for a real pilot, patch, integration dry-run, or honest blocker.","The tool enforces a 10-30 second duration window and records requested duration, measured duration, recorder backend, video hash, and paths in receipt.json.","Linux auto mode prefers wf-recorder on Wayland and ffmpeg x11grab on X11; macOS uses ffmpeg avfoundation and may require Screen Recording permission.","Pass --factory-run and --task to colocate evidence under the Factory task bundle.","Use --dry-run for planning and --recorder synthetic only for smoke testing the evidence plumbing, not for product proof.","See docs/demo-evidence.md for worker handoff and troubleshooting guidance."]},{"id":"factory","name":"Cento Factory","lane":"agent ops","kind":"python","entrypoint":"./scripts/factory.py","description":"Orchestration substrate for deterministic intake, planning, materialization, queueing, dry-run dispatch, patch collection, validation, integration, release candidates, and hubs.","platforms":["linux","macos"],"commands":["cento factory --help","cento factory intake \"develop me a career consulting module\" --dry-run --out workspace/runs/factory/factory-planning-e2e","cento factory plan workspace/runs/factory/factory-planning-e2e --no-model","cento factory materialize workspace/runs/factory/factory-planning-e2e","cento factory queue workspace/runs/factory/factory-planning-e2e","cento factory dispatch workspace/runs/factory/factory-planning-e2e --lane builder --max 4 --dry-run","cento factory collect workspace/runs/factory/factory-planning-e2e","cento factory validate workspace/runs/factory/factory-planning-e2e","cento factory integrate workspace/runs/factory/factory-planning-e2e --dry-run","cento factory validate-fanout factory-integration-e2e --max-parallel 32 --json","cento factory merge factory-integration-e2e --auto-merge-main --dry-run --json","cento factory merge factory-integration-e2e --auto-merge-main --push --json","cento factory status workspace/runs/factory/factory-planning-e2e"],"outputs":["workspace/runs/factory/<run>/factory-plan.json","workspace/runs/factory/<run>/queue.json","workspace/runs/factory/<run>/integration/apply-plan.json","workspace/runs/factory/<run>/integration/validation-fanout.json","workspace/runs/factory/<run>/integration/merge-receipt.json"],"notes":["Factory remains deterministic by default. Use `cento build` for the manifest-owned local build package v1 slice.","Factory is the preferred execution spine for real Patch Swarm pilots; adapt facade/status gaps instead of creating a new runtime.","Live Taskstream creation and patch application remain explicit opt-in operations on Factory commands.","Factory validate-fanout runs cacheable candidate checks in parallel before serialized Safe Integrator apply.","Factory merge --auto-merge-main is the only automatic main/push gate and requires release, rollback, validation, clean-worktree, and post-merge receipts.","Factory merge --auto-merge-main --dry-run writes merge readiness evidence without merging or pushing."]},{"id":"build","name":"Cento Build","lane":"agent ops","kind":"python","entrypoint":"./scripts/cento_build.py","description":"Patch unit and safety substrate for manifest-owned paths, Builder prompts, patch bundles, dry-run integration, safe apply, and receipts.","platforms":["linux","macos"],"commands":["cento build --help","cento build init --task \"Fixture docs page patch\" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture","cento build check tests/fixtures/cento_build/manifest.valid.json","cento build prompt tests/fixtures/cento_build/manifest.valid.json","cento build artifact check tests/fixtures/cento_build/worker_artifact.valid.json","cento build worker run .cento/builds/<id>/manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180","cento build worker run .cento/builds/<id>/manifest.json --worker builder_1 --runtime-profile codex-fast --worktree","cento build worker run .cento/builds/<id>/manifest.json --worker builder_1 --runtime command --command \"codex exec --prompt-file {prompt}\" --allow-unsafe-command --worktree --timeout 180","cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff","cento build integrate .cento/builds/<id>/manifest.json --bundle .cento/builds/<id>/workers/builder_1/patch_bundle.json --worktree --dry-run","cento build apply .cento/builds/<id>/manifest.json --bundle .cento/builds/<id>/workers/builder_1/patch_bundle.json --from-receipt .cento/builds/<id>/integration_receipt.json","cento build receipt .cento/builds/build_fixture_docs_page_001"],"outputs":[".cento/builds/<build_id>/manifest.json",".cento/builds/<build_id>/builder.prompt.md",".cento/builds/<build_id>/workers/builder_1/worker_artifact.json",".cento/builds/<build_id>/workers/builder_1/patch_bundle.json",".cento/builds/<build_id>/integration_receipt.json",".cento/builds/<build_id>/apply_receipt.json",".cento/builds/<build_id>/validation_receipt.json",".cento/builds/<build_id>/taskstream_evidence.json",".cento/builds/<build_id>/events.ndjson"],"notes":["Local-only v1.2; one fixture/local worker can be launched, but there are no cloud workers, API calls, scheduler, PR creation, or automatic model patch generation.","Treat Build patch bundles and integration receipts as canonical before adding Patch Swarm bundle or apply formats.","Use `cento runtime check codex-fast` and `--runtime-profile codex-fast` for hardened command runtime profiles. Raw shell command runtimes require `--allow-unsafe-command`.","The normal integration path requires a patch bundle. Raw patch integration is rejected unless explicitly run as a dev raw-patch path.","The core acceptance behavior is rejecting dirty owned paths, unowned paths, protected paths, binary patches, path traversal, and undeclared lockfile changes.","`cento build apply` requires an accepted integration receipt and writes apply/taskstream evidence receipts."]},{"id":"runtime","name":"Cento Runtime Profiles","lane":"agent ops","kind":"python","entrypoint":"./scripts/cento_runtime.py","description":"Inspect and validate local builder runtime profiles used by Cento Build worker execution.","platforms":["linux","macos"],"commands":["cento runtime list","cento runtime list --json","cento runtime check codex-fast","cento runtime check codex-fast --json","cento runtime check claude-code-fast --json","cento runtime check python-fixture --require-executable"],"outputs":[".cento/runtimes.yaml"],"notes":["Runtime profiles use argv arrays, scrubbed environment allowlists, explicit timeouts, and isolated worktrees for command workers.","`claude-code-fast` is available as the Claude Code command-runtime adapter used by Patch Swarm.","`cento runtime check` validates the profile contract. Missing executables are warnings unless `--require-executable` is passed.","This tool does not launch workers; `cento build worker run --runtime-profile NAME --worktree` owns execution."]},{"id":"workset","name":"Cento Workset","lane":"agent ops","kind":"python","entrypoint":"./scripts/cento_workset.py","description":"Parallel lease substrate for exclusive-path N-worker tasks, structured API artifacts, dependency gates, budget caps, and sequential integration.","platforms":["linux","macos"],"commands":["cento workset check tests/fixtures/cento_workset/workset.valid.json","cento workset check tests/fixtures/cento_workset/workset.execute.api.json --runtime api-openai","cento workset check tests/fixtures/cento_workset/workset.overlap.json","cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke","cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke","cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke","cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke","cento workset materialize-artifact .cento/worksets/<run_id>/workers/<worker_id>/artifact.json"],"outputs":[".cento/worksets/<run_id>/workset.json",".cento/worksets/<run_id>/leases.json",".cento/worksets/<run_id>/workset_receipt.json",".cento/worksets/<run_id>/workset_evidence.json",".cento/worksets/<run_id>/events.ndjson",".cento/worksets/<run_id>/workers/<worker_id>/artifact.json",".cento/worksets/<run_id>/workers/<worker_id>/cost_receipt.json",".cento/worksets/<run_id>/workers/<worker_id>/worker_receipt.json",".cento/builds/workset_<run_id>_<task_id>/*"],"notes":["Workset v1 rejects overlapping write paths and glob write paths. Every task must have exclusive write_paths.","Use Workset exclusive paths as the source of truth for parallel lease semantics before adding Patch Swarm lease concepts.","Plain `cento workset check WORKSET` rejects missing write paths. API-worker-created file plans must declare `--runtime api-openai` or `--allow-creates`.","Workers run in parallel only until patch or structured artifact collection. Integration and apply are always sequential.","OpenAI API workers use Responses API structured outputs and do not mutate repo files directly.","API worker budgets have a target and hard max; budget-blocked workers still write cost receipts.","Dependency gates are intentionally simple: a task dispatches only after depends_on tasks are completed and applied.","Shared-file edits require a separate serialized integrator task; no smart merge or conflict resolution is attempted."]},{"id":"object-storage","name":"Oracle Object Storage","lane":"cloud ops","kind":"python","entrypoint":"./scripts/object_storage.py","description":"Write dummy objects and mirror Cento run images to private Oracle Object Storage through the OCI CLI.","platforms":["linux","macos"],"commands":["cento object-storage status","cento object-storage status --probe --json","cento object-storage ensure-bucket --name cento-images-standard --region us-ashburn-1 --namespace NAMESPACE --json","cento object-storage put-dummy --dry-run --json","cento object-storage put-dummy --region us-ashburn-1 --bucket CENTO_BUCKET --namespace NAMESPACE --json","cento object-storage e2e --json","cento object-storage e2e --live --region us-ashburn-1 --bucket CENTO_BUCKET --namespace NAMESPACE --json","cento object-storage plan-images --root workspace/runs --bucket cento-images-standard --namespace NAMESPACE --region us-ashburn-1 --json","cento object-storage upload-images --manifest workspace/runs/object-storage/<run-id>/manifest.json --live --json","cento object-storage verify-images --manifest workspace/runs/object-storage/<run-id>/upload-receipt.json --sample 10 --json"],"outputs":["workspace/runs/object-storage/<run-id>/dummy.txt","workspace/runs/object-storage/<run-id>/receipt.json","workspace/runs/object-storage/<run-id>/summary.md","workspace/runs/object-storage/<run-id>/e2e-summary.json","workspace/runs/object-storage/<run-id>/e2e-summary.md","workspace/runs/object-storage/<run-id>/manifest.json","workspace/runs/object-storage/<run-id>/upload-receipt.json","workspace/runs/object-storage/<run-id>/verify-receipt.json","OCI object: oci://<namespace>/<bucket>/<object-name>"],"docs":["docs/oci-image-migration.html","docs/oci-image-migration.md"],"notes":["Uses the installed OCI CLI instead of adding a Python SDK dependency.","Bucket defaults to CENTO_OBJECT_STORAGE_BUCKET; namespace defaults to CENTO_OBJECT_STORAGE_NAMESPACE and otherwise lets the OCI CLI discover it.","Region can be passed with --region or CENTO_OBJECT_STORAGE_REGION when the default OCI config region is not the Object Storage region to use.","Dry-run mode never calls OCI; it copies the dummy file to uploaded/ and verifies the hash.","Image migration is mirror-only: local originals are never deleted, truncated, or replaced.","Image objects are content-addressed by sha256 and sensitive-looking paths are blocked from upload.","Human runbook: docs/oci-image-migration.html; Markdown source: docs/oci-image-migration.md"]},{"id":"proreq-light","name":"ProReq Light","lane":"agent ops","kind":"python","entrypoint":"./scripts/proreq_light.py","description":"Run the Hard ProReq artifact chain with the Pro planning request replaced by Codex Exec using a ChatGPT Pro simulation prompt.","platforms":["linux","macos"],"commands":["cento proreq-light all","cento proreq-light pro-request","cento proreq-light codex-plan","cento proreq-light backend-work","cento proreq-light validation-plan","cento proreq-light deliver --max-parallel 3 --runtime-profile codex-fast --json"],"outputs":["workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/proreq_light_codex_prompt.md","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/proreq_light_output_schema.json","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/proreq_light_codex_command.json","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/proreq_light_codex_response.json","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/pro_backend_plan.json","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/backend_work_manifest.json","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/validation_plan.json","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/closed_loop_delivery.json","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/closed_loop_evidence.md","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/<run-id>/closed_loop_incident.md"],"docs":["docs/dev-pipeline-run-contracts.md"],"notes":["The prompt starts with `You're chatGPT Pro model` and asks read-only Codex Exec to emulate the Hard ProReq Pro planning lane.","The output schema remains `cento.hard_proreq_backend_plan.v1`, so downstream story/workset/integration artifacts stay compatible.","`deliver` turns accepted ProReq-light worksets into local Codex worker launches, sequential integration, validation, evidence, and incident receipts.","This route does not use live OpenAI Pro API, image API dispatch, or OpenAI API workers; if Codex Exec is unavailable or times out, it records the issue and falls back to deterministic planning."]},{"id":"foundry","name":"Cento Tool Foundry","lane":"agent ops","kind":"python","entrypoint":"./scripts/tool_foundry.py","description":"Create Cento-native business tools through Factory, Workset, parallel train promotion, storage policy, cost receipts, and demo evidence.","platforms":["linux","macos"],"commands":["cento foundry create \"client intake hub\" --domain career-consulting --max-parallel 6 --budget-usd 10 --max-budget-usd 20 --json","cento foundry plan RUN_ID --json","cento foundry execute RUN_ID --runtime fixture --json","cento foundry execute RUN_ID --runtime api-openai --budget-usd 10 --max-budget-usd 20 --json","cento foundry promote RUN_ID --dry-run --json","cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --dry-run --json","cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --apply --json","cento foundry status RUN_ID --json","cento foundry validate RUN_ID --json","cento foundry e2e --fixture client-intake-hub --dry-run --json","cento foundry e2e --fixture client-intake-hub --dry-run --real-files --target-root templates/foundry/client-intake-hub --json","cento foundry e2e --fixture client-intake-hub --live --budget-usd 10 --max-budget-usd 20 --json"],"outputs":["workspace/runs/foundry/<run-id>/foundry-spec.json","workspace/runs/foundry/<run-id>/factory_handoff.json","workspace/runs/foundry/<run-id>/workset.json","workspace/runs/foundry/<run-id>/workset_check.json","workspace/runs/foundry/<run-id>/execution_receipt.json","workspace/runs/foundry/<run-id>/cost_receipt.json","workspace/runs/foundry/<run-id>/storage-policy.json","workspace/runs/foundry/<run-id>/demo-evidence.json","workspace/runs/foundry/<run-id>/real_file_manifest.json","workspace/runs/foundry/<run-id>/materialization_plan.json","workspace/runs/foundry/<run-id>/materialization_receipt.json","workspace/runs/foundry/<run-id>/validation_summary.json","templates/foundry/client-intake-hub/","docs/client-intake-hub.md","workspace/runs/parallel-delivery/train/foundry-<run-id>-train/","workspace/runs/factory/parallel-train-foundry-<run-id>-train/"],"docs":["docs/tool-foundry.md","docs/client-intake-hub.md"],"notes":["Foundry is a facade over existing Cento primitives; it does not introduce a second scheduler or integrator.","The first fixture tool is the career consulting Client Intake Hub.","Dry-run/fixture mode costs $0 and is the required repeatable validation path.","Live api-openai execution requires both --budget-usd and --max-budget-usd, and v1 rejects hard caps above $20.","Client data is local-first; real resumes, LinkedIn exports, notes, and PII are never uploaded by default.","Workset execution uses tracked fixture targets while run-scoped artifacts carry the generated product bundle and evidence.","Real-file materialization plans or applies repo-ready Client Intake Hub files under templates/foundry/client-intake-hub plus docs/client-intake-hub.md.","Materialization dry-run is the default; apply skips identical files and blocks changed existing targets instead of overwriting them."]},{"id":"parallel-delivery","name":"Parallel AI Delivery","lane":"agent ops","kind":"python","entrypoint":"./scripts/parallel_delivery.py","description":"Patch Swarm and Parallel AI Delivery product facade over Factory orchestration, Build patch units, Workset leases, Agent Work lifecycle, and worker visibility.","platforms":["linux","macos"],"commands":["cento parallel-delivery plan --json","cento parallel-delivery execute --sleep-seconds 1 --json","cento parallel-delivery execute --live-pro --sleep-seconds 1 --json","cento parallel-delivery demo --json","cento parallel-delivery validate --json","cento parallel-delivery status --json","cento parallel-delivery train plan --workset tests/fixtures/cento_workset/workset.valid.json --max-parallel 10 --json","cento parallel-delivery train run RUN_ID --simulate --json","cento parallel-delivery train run RUN_ID --workset-execute --runtime fixture --validation smoke --allow-dirty-owned --json","cento parallel-delivery train promote RUN_ID --dry-run --json","cento parallel-delivery train e2e --workset tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --allow-dirty-owned --dry-run --json","cento parallel-delivery train integrate RUN_ID --dry-run --json","cento parallel-delivery train status RUN_ID --json","cento parallel-delivery train validate RUN_ID --json","cento parallel-delivery patch-swarm plan --candidate-target 100 --max-parallel-agents 5 --json","cento parallel-delivery patch-swarm split --request-file REQUEST.md --candidate-target 20 --max-parallel-agents 5 --mode no-model --json","cento parallel-delivery patch-swarm leases --run-dir workspace/runs/parallel-delivery/lease-fixture --run-id lease-fixture --fixture --json","cento parallel-delivery patch-swarm validate-leases --run-dir workspace/runs/parallel-delivery/lease-fixture --json","cento parallel-delivery patch-swarm prompts --run-dir workspace/runs/parallel-delivery/proreq-fixture --count 20 --lane all --chatgpt-pro --copy-to-temp --json","cento parallel-delivery patch-swarm worker-packets --run-dir workspace/runs/parallel-delivery/codex-packets-fixture --run-id codex-packets-fixture --fixture --count 10 --json","cento parallel-delivery patch-swarm dispatch --run-dir workspace/runs/parallel-delivery/worker-status-fixture --run-id worker-status-fixture --candidate-target 100 --max-parallel-agents 5 --dry-run --fixture --json","cento parallel-delivery patch-swarm worker-status --run-dir workspace/runs/parallel-delivery/worker-status-fixture --json","cento parallel-delivery status --run worker-status-fixture --run-root workspace/runs/parallel-delivery --json","cento parallel-delivery patch-bundles validate --bundle workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles/bundle-safe-001.json --lease-manifest workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json --out workspace/runs/parallel-delivery/patch-bundle-fixture --base-commit HEAD --json","cento parallel-delivery patch-bundles collect --run-id patch-bundle-fixture --bundles-dir workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles --lease-manifest workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json --out workspace/runs/parallel-delivery/patch-bundle-fixture --base-commit HEAD --json","cento parallel-delivery release-candidate create --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json --out workspace/runs/parallel-delivery/release-candidate-fixture/dry-run --mode dry-run --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo --base-commit HEAD --json","cento parallel-delivery release-candidate create --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json --out workspace/runs/parallel-delivery/release-candidate-fixture/apply --mode apply --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo --target-worktree workspace/runs/parallel-delivery/release-candidate-fixture/integration-worktree --base-commit HEAD --final-validation-cmd \"python -m pytest -q tests\" --json","cento parallel-delivery taskstream emit --split-plan workspace/runs/parallel-delivery/taskstream-fixture/input/split-plan.json --out workspace/runs/parallel-delivery/taskstream-fixture --transport manifest-only --run-preflight","cento parallel-delivery taskstream preflight --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages --out workspace/runs/parallel-delivery/taskstream-fixture/preflight","cento parallel-delivery taskstream apply --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages --out workspace/runs/parallel-delivery/taskstream-fixture/apply --transport agent-work --apply","cento parallel-delivery patch-swarm execute RUN_ID --fixture --json","cento parallel-delivery patch-swarm execute RUN_ID --live --budget-cap-usd 1 --max-budget-usd 1 --api-sandbox-candidates 1 --json","cento parallel-delivery patch-swarm integrate RUN_ID --dry-run --json","cento parallel-delivery patch-swarm integrate RUN_ID --apply --factory-run workspace/runs/factory/patch-swarm-RUN_ID --validate-each --json","cento parallel-delivery patch-swarm validate RUN_ID --json","cento parallel-delivery patch-swarm status RUN_ID --json","cento parallel-delivery patch-swarm status --run-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 --write-html --json","cento parallel-delivery patch-swarm e2e --candidate-target 30 --max-parallel-agents 3 --fixture --json","cento parallel-delivery patch-swarm e2e --candidate-target 25 --max-parallel-agents 5 --fixture --run-id fixture-console-25 --output-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 --json","cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --run-root workspace/runs/parallel-delivery/e2e-fixture --json","cento parallel-delivery self-improve run --json","cento parallel-delivery self-improve e2e --candidate-target 30 --max-parallel-agents 3 --budget-cap-usd 1 --max-budget-usd 1 --apply --validate-each --auto-merge-gate --json","cento parallel-delivery self-improve validate --json","cento parallel-delivery self-improve status --json","cento parallel-delivery self-improve install-cron --time 02:30"],"outputs":["workspace/runs/parallel-delivery/<run>/implementation_manifest.json","workspace/runs/parallel-delivery/<run>/proreq_receipt.json","workspace/runs/parallel-delivery/<run>/execution_manifest.json","workspace/runs/parallel-delivery/<run>/validation_summary.json","workspace/runs/parallel-delivery/<run>/demo/demo_receipt.json","workspace/runs/parallel-delivery/train/<run>/train_manifest.json","workspace/runs/parallel-delivery/train/<run>/workset.json","workspace/runs/parallel-delivery/train/<run>/workset_check.json","workspace/runs/parallel-delivery/train/<run>/integration_queue.json","workspace/runs/parallel-delivery/train/<run>/train_receipt.json","workspace/runs/parallel-delivery/train/<run>/workset_execute_command.json","workspace/runs/parallel-delivery/train/<run>/workset_execute_result.json","workspace/runs/parallel-delivery/train/<run>/promotion_manifest.json","workspace/runs/parallel-delivery/train/<run>/promotion_decision.json","workspace/runs/parallel-delivery/train/<run>/promotion_decision.md","workspace/runs/parallel-delivery/train/<run>/factory_handoff.json","workspace/runs/factory/parallel-train-<run>/factory-plan.json","workspace/runs/factory/parallel-train-<run>/integration/apply-plan.json","workspace/runs/parallel-delivery/train/<run>/events.ndjson","workspace/runs/parallel-delivery/train/<run>/decision_report.md","workspace/runs/parallel-delivery/patch-swarm/<run>/patch_swarm_manifest.json","workspace/runs/parallel-delivery/patch-swarm/<run>/proreq_execution_manifest.json","workspace/runs/parallel-delivery/patch-swarm/<run>/candidate_index.json","workspace/runs/parallel-delivery/patch-swarm/<run>/dedupe_clusters.json","workspace/runs/parallel-delivery/patch-swarm/<run>/ranking.json","workspace/runs/parallel-delivery/patch-swarm/<run>/cost_ledger.json","workspace/runs/parallel-delivery/patch-swarm/<run>/usage_guard.json","workspace/runs/parallel-delivery/patch-swarm/<run>/provider_usage.jsonl","workspace/runs/parallel-delivery/patch-swarm/<run>/candidate_spend_ledger.jsonl","workspace/runs/parallel-delivery/patch-swarm/<run>/patch_swarm_receipt.json","workspace/runs/parallel-delivery/patch-swarm/<run>/integration_execution/integration_execution.json","workspace/runs/parallel-delivery/patch-swarm/<run>/safe_integrator_handoff.json","workspace/runs/parallel-delivery/patch-swarm/<run>/factory_promotion.json","workspace/runs/parallel-delivery/patch-swarm/<run>/validation_summary.json","workspace/runs/parallel-delivery/patch-swarm/<run>/ui_state.json","workspace/runs/parallel-delivery/patch-swarm/<run>/decision_report.md","workspace/runs/parallel-delivery/planner-fixture/split-plan.json","workspace/runs/parallel-delivery/planner-fixture/task-graph.json","workspace/runs/parallel-delivery/planner-fixture/task-contracts/task-0001.md","workspace/runs/parallel-delivery/lease-fixture/path-leases.json","workspace/runs/parallel-delivery/lease-fixture/lease-conflicts.json","workspace/runs/parallel-delivery/lease-fixture/lease-validation.json","workspace/runs/parallel-delivery/lease-fixture/workset-manifest.json","workspace/runs/parallel-delivery/lease-fixture/workset-compatibility.json","workspace/runs/parallel-delivery/proreq-fixture/prompt-bundle.json","workspace/runs/parallel-delivery/proreq-fixture/prompt-index.json","workspace/runs/parallel-delivery/proreq-fixture/prompts/prompt-0001-master.md","workspace/runs/parallel-delivery/proreq-fixture/prompts/prompt-0020-evidence.md","workspace/runs/parallel-delivery/proreq-fixture/temp-bridge.json","workspace/runs/parallel-delivery/codex-packets-fixture/codex-packet-bundle.json","workspace/runs/parallel-delivery/codex-packets-fixture/codex-packet-index.json","workspace/runs/parallel-delivery/codex-packets-fixture/packets/task-0001-codex-packet.md","workspace/runs/parallel-delivery/worker-status-fixture/worker-pool-plan.json","workspace/runs/parallel-delivery/worker-status-fixture/dry-run-dispatch.json","workspace/runs/parallel-delivery/worker-status-fixture/worker-queue-ledger.jsonl","workspace/runs/parallel-delivery/worker-status-fixture/worker-status.json","workspace/runs/parallel-delivery/worker-status-fixture/stale-workers.json","workspace/runs/parallel-delivery/worker-status-fixture/process-visibility.json","workspace/runs/parallel-delivery/worker-status-fixture/console-status.json","workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json","workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles/*.json","workspace/runs/parallel-delivery/patch-bundle-fixture/input/patches/*.diff","workspace/runs/parallel-delivery/patch-bundle-fixture/receipts/*.json","workspace/runs/parallel-delivery/patch-bundle-fixture/patch-bundle-report.json","workspace/runs/parallel-delivery/patch-bundle-fixture/patch-bundle-report.md","workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json","workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.rejected.json","workspace/runs/parallel-delivery/release-candidate-fixture/input/bundle-receipts/*.json","workspace/runs/parallel-delivery/release-candidate-fixture/input/patches/*.diff","workspace/runs/parallel-delivery/release-candidate-fixture/dry-run/apply-report.json","workspace/runs/parallel-delivery/release-candidate-fixture/apply/apply-report.json","workspace/runs/parallel-delivery/release-candidate-fixture/apply/release-candidate.json","workspace/runs/parallel-delivery/release-candidate-fixture/apply/release-notes.md","workspace/runs/parallel-delivery/release-candidate-fixture/apply/rollback-metadata.json","workspace/runs/parallel-delivery/e2e-fixture/<run-id>/validation-summary.json","workspace/runs/parallel-delivery/e2e-fixture/<run-id>/validation-report.md","workspace/runs/parallel-delivery/e2e-fixture/<run-id>/worker-packets/codex-packet-index.json","workspace/runs/parallel-delivery/e2e-fixture/<run-id>/integration/integration-receipt.json","workspace/runs/parallel-delivery/e2e-fixture/<run-id>/release-candidate/release-candidate.json","workspace/runs/parallel-delivery/console-fixture/<run-id>/console-data.json","workspace/runs/parallel-delivery/console-fixture/<run-id>/start-here.html","workspace/runs/parallel-delivery/console-fixture/<run-id>/link-check.json","workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/patch-swarm/latest_ui_state.json","workspace/runs/ai-self-improvement-nightly/<run>/nightly_cycle_manifest.json","workspace/runs/ai-self-improvement-nightly/<run>/validation_gates.json","workspace/runs/ai-self-improvement-nightly/<run>/loop_metrics.json","workspace/runs/ai-self-improvement-nightly/<run>/promotion_recommendation.json","workspace/runs/ai-self-improvement-nightly/<run>/evidence_handoff.json","workspace/runs/ai-self-improvement-nightly/<run>/next_cycle_request.json","workspace/runs/ai-self-improvement-e2e/<run>/e2e_manifest.json","workspace/runs/ai-self-improvement-e2e/<run>/self_improve_source.json","workspace/runs/ai-self-improvement-e2e/<run>/patch_swarm_result.json","workspace/runs/ai-self-improvement-e2e/<run>/factory_promotion.json","workspace/runs/ai-self-improvement-e2e/<run>/safe_integrator_apply.json","workspace/runs/ai-self-improvement-e2e/<run>/auto_merge_gate.json","workspace/runs/ai-self-improvement-e2e/<run>/spend_summary.json","workspace/runs/ai-self-improvement-e2e/<run>/validation_summary.json","workspace/runs/ai-self-improvement-e2e/<run>/handoff.md","workspace/runs/ai-self-improvement-e2e/latest/e2e_manifest.json","workspace/runs/parallel-delivery/taskstream-fixture/input/split-plan.json","workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/story.json","workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/validation.json","workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/handoff.md","workspace/runs/parallel-delivery/taskstream-fixture/taskstream-handoff-report.json","workspace/runs/parallel-delivery/taskstream-fixture/taskstream-handoff-report.md","workspace/runs/parallel-delivery/taskstream-fixture/validation-summary.txt"],"docs":["docs/ai-self-improvement-autopilot.md","docs/ai-self-improvement-nightly.md","docs/parallel-integration-train.md","docs/parallel-ai-delivery-roadmap.md","docs/parallel-delivery/patch-swarm-artifacts.md","docs/parallel-delivery/patch-swarm-planner.md","docs/parallel-delivery/patch-swarm-leasing.md","docs/parallel-delivery/patch-swarm-proreq-prompts.md","docs/parallel-delivery/patch-swarm-codex-worker-packets.md","docs/parallel-delivery/patch-swarm-console.md","docs/parallel-delivery/patch-bundle-validation.md","docs/parallel-delivery/release-candidate-safe-apply.md","docs/parallel-delivery/patch-swarm-validation-e2e.md","docs/parallel-delivery/patch-swarm-taskstream.md","docs/parallel-delivery/patch-swarm-worker-status.md","docs/patch-swarm.md"],"notes":["Routes VP-level parallel delivery planning through existing Hard ProReq and Workset pipelines instead of inventing a new orchestration path.","The 100-call responsibility audit treats Parallel Delivery/Patch Swarm as the product facade, not the owner of duplicate queues, leases, patch bundles, task manifests, release-candidate formats, or dashboards.","Future Patch Swarm phases should run real low-risk pilots through existing Factory/Build/Workset/Agent Work surfaces with live dispatch off, then add only thin adapters for proven reuse breaks.","Default execution keeps live Pro disabled unless `--live-pro` is passed; image requests still follow the configured Hard ProReq image lane.","The train subcommands plan high-parallel Workset shards and a sequential dry-run integration queue without patch apply.","Train run accepts explicit --simulate or --workset-execute; the Workset mode calls `cento workset execute` and records command/result/receipt artifacts without passing --apply.","Train api-openai execution is explicit and requires both --budget-usd and --max-budget-usd.","Train promote converts completed Workset receipts into a Factory Safe Integrator handoff and apply plan; dry-run is the default.","Train e2e runs plan, Workset execute, train validation, and Factory promotion in one command.","Train simulation still requires explicit --dry-run for the separate integration step.","Patch Swarm plans ten ProReq execution lanes plus one dedicated serialized integrator for massively parallel patch candidate generation.","Patch Swarm providers normalize `codex exec`, Claude Code, and OpenAI structured patch proposals into candidate_patch.v1 receipts.","Patch Swarm fixture e2e can run small candidate targets for sandbox gates, ranks them, selects one winner per ProReq lane, and writes a Safe Integrator handoff without mutating the main worktree.","Patch Swarm live api-openai execution is fail-closed behind a live-enabled plan, --budget-cap-usd, --max-budget-usd, provider spend estimate, OPENAI_API_KEY, and a bounded api-patch-proposal sandbox candidate limit.","Patch Swarm integrate --apply promotes selected winners into Factory patch bundles, runs validate-fanout, and applies only through Factory/Safe Integrator worktrees.","Patch Swarm mirrors ui_state.json into Dev Pipeline Studio so the existing parallel execution UI can show candidates, provider mix, costs, validation, winners, and integration status.","Patch Swarm artifact schemas and fixture validation are documented in docs/parallel-delivery/patch-swarm-artifacts.md; the helper is scripts/parallel_delivery_artifacts.py and does not dispatch workers or apply patches.","Patch Swarm split creates bounded split-plan.json, task-graph.json, and task contract drafts through scripts/parallel_delivery_planner.py; no-model mode treats 100 as a cap rather than a target.","Patch Swarm leases create deterministic path-leases.json, conflict reports, dependency gates, Workset-compatible manifests, and operation validation through scripts/parallel_delivery_leases.py without applying patches.","Patch Swarm prompts generate local-only ChatGPT Pro copy/paste prompt bundles through scripts/parallel_delivery_prompts.py; they do not call live AI services by default.","Patch Swarm worker-packets emits local Codex-ready Markdown packets from split-plan, task-graph, and path-leases artifacts; it does not dispatch Codex or apply patches.","Patch Swarm worker-status plans bounded dry-run dispatch through scripts/parallel_delivery_worker_status.py, writes worker queue/status/process visibility artifacts, and does not launch external agents by default.","Patch Swarm patch-bundles collect local worker bundle manifests, validate diffs against authoritative leases through scripts/parallel_delivery_patch_bundles.py, and write receipts/reports without applying patches.","Parallel Delivery release-candidate create reads accepted integration receipts, validates accepted bundle receipts and patch hashes, dry-runs by default, applies only in isolated target worktrees when --mode apply is explicit, and writes apply receipts, rollback metadata, release notes, and release-candidate.json.","Patch Swarm validation e2e composes split planning, path leases, worker packets, simulated fixture patch bundles, deterministic validation, dry-run integration, and fixture release-candidate evidence through scripts/parallel_delivery_validation_e2e.py.","The self-improvement loop runs four sequential Hard ProReq planning passes, validates artifacts, recommends promotion, writes the next-cycle request, and stops before implementation dispatch.","Self-improvement artifacts mirror to workspace/runs/ai-self-improvement-nightly/latest/.","Self-improvement e2e connects latest next_cycle_request.json to Patch Swarm, Factory validate-fanout, bounded Safe Integrator apply, and factory merge --auto-merge-main --dry-run without pushing main.","A gpt-image-2 403 is recorded as nonblocking image evidence and does not fail backend planning.","The demo uses ten fixture workers, max_parallel 10, sequential dry-run integration, and zero repository mutations.","Validation requires all Hard ProReq passes to complete, every generated workset to pass `cento workset check`, and the demo receipt to pass.","Patch Swarm taskstream emits existing agent-work story.json and validation.json manifests from split-plan artifacts; dry-run manifest generation is the default.","Patch Swarm taskstream apply refuses live Taskstream creation unless --apply is present and routes live work through cento agent-work rather than direct database writes."]}]}; -const CLI = {"name":"cento","summary":"Unified cento CLI facade for built-ins, tool dispatch, alias dispatch, shell integration, and terminal help browsing.","usage":"cento <command> [args...]","commands":[{"name":"help","summary":"Show root cento CLI help.","usage":"cento help","flags":[{"name":"-h, --help","summary":"Show root CLI help without naming the help subcommand.","usage":"cento --help"}],"examples":["cento help","cento --help"]},{"name":"interactive","summary":"Open the Bubble Tea TUI for built-ins, tools, aliases, and docs.","usage":"cento interactive","flags":[],"examples":["cento interactive","cento interactive --section builtins","cento interactive --entry conf"]},{"name":"docs","summary":"Print cento CLI docs from the canonical JSON source.","usage":"cento docs [ENTRY] [--json|--path]","flags":[{"name":"--json","summary":"Print the raw JSON document.","usage":"cento docs --json"},{"name":"--path","summary":"Print the path to the canonical JSON document.","usage":"cento docs --path"}],"examples":["cento docs","cento docs conf","cento docs --json","cento docs --path"]},{"name":"tools","summary":"List registered cento tools from the tool registry.","usage":"cento tools","flags":[],"examples":["cento tools"]},{"name":"aliases","summary":"List configured user aliases.","usage":"cento aliases","flags":[],"examples":["cento aliases"]},{"name":"conf","summary":"Open or print the cento alias config file.","usage":"cento conf [--path]","flags":[{"name":"--path","summary":"Print the config path instead of opening it in an editor.","usage":"cento conf --path"}],"examples":["cento conf","cento conf --path"]},{"name":"completion","summary":"Print shell completion for a supported shell.","usage":"cento completion zsh","flags":[],"examples":["cento completion zsh"]},{"name":"install","summary":"Install cento shell and terminal integration.","usage":"cento install [zsh|tmux|terminal|all]","flags":[],"examples":["cento install","cento install zsh","cento install tmux","cento install terminal"],"details":["`zsh` installs completion under ~/.config/cento/completions/_cento, writes ~/.config/cento/init.zsh, injects one guarded source block into ~/.zshrc, and adds a right-prompt segment like [cento:linux:host].","`tmux` writes ~/.config/cento/tmux.conf, injects one guarded source block into ~/.tmux.conf, and reloads tmux when a server is running.","`terminal` and `all` install the Zsh/Oh My Zsh prompt and completion path only."]},{"name":"tmux","summary":"Manage the cento tmux status badge integration.","usage":"cento tmux [badge|install|status|docs]","flags":[],"examples":["cento tmux badge","cento tmux status","cento tmux install","cento tmux docs"],"details":["`cento tmux badge` prints the short label rendered in tmux status-left.","Set CENTO_TMUX_BADGE to override the label and CENTO_TMUX_BADGE_HOST=1 to append the short hostname."]},{"name":"run","summary":"Run a registered tool by id.","usage":"cento run TOOL [args...]","flags":[],"examples":["cento run scan --query \"mcp\"","cento run crm docs"]}]}; +const CLI = {"name":"cento","summary":"Unified cento CLI facade for built-ins, tool dispatch, alias dispatch, shell integration, and terminal help browsing.","usage":"cento <command> [args...]","notes":["Registered tools can be invoked directly as `cento TOOL [args...]`.","Configured aliases can be invoked directly as `cento ALIAS [args...]`.","Use `cento docs` or `cento interactive` when you need the built-in command surface explained from the canonical JSON source.","When an operator asks to save something in Docs, default to human-facing files under `docs/` and update navigation when discoverability matters; command-reference docs are a separate surface.","`cento install terminal` installs the managed Zsh/Oh My Zsh completion init plus the Cento prompt segment."],"checklist":[{"name":"Discover","summary":"Start with `cento docs`, `cento tools`, and a repo search before adding a new command or workflow."},{"name":"Task","summary":"For Cento feature, automation, MCP, cluster, mobile, UI, or command behavior changes, create an `agent-work` story manifest and task before implementation."},{"name":"Align","summary":"Keep `data/cento-cli.json`, affected docs in `docs/`, and any generated indexes aligned with the actual command surface."},{"name":"Validate","summary":"Run the narrow deterministic checks for the files changed, including JSON validation for docs sources."},{"name":"Evidence","summary":"Leave validation evidence in the relevant `workspace/runs/agent-work/<issue-id>/` bundle and update Taskstream status."}],"routing":[{"name":"tool-routing","usage":"cento TOOL [args...]","summary":"Run a registered tool directly by id, including `cento build` for manifest-owned local worker patch contracts and `cento factory` for plan, dispatch, and Safe Integrator artifacts."},{"name":"alias-routing","usage":"cento ALIAS [args...]","summary":"Run a configured alias directly from `~/.config/cento/aliases.sh`."},{"name":"routing-nativeness-loop","usage":"cento walk-autopilot routing run --json","summary":"Collect counts-only routing, observability, Agent Work, skill usage, and cento-native drift stats; write a decision report; and create or update one bounded Agent Work follow-up without implementing from cron."}],"commands":[{"name":"help","summary":"Show root cento CLI help.","usage":"cento help","flags":[{"name":"-h, --help","summary":"Show root CLI help without naming the `help` subcommand.","usage":"cento --help"}],"examples":["cento help","cento --help"]},{"name":"interactive","summary":"Open the Bubble Tea TUI for built-ins, tools, aliases, and docs.","usage":"cento interactive","flags":[],"examples":["cento interactive","cento interactive --section builtins","cento interactive --entry conf"]},{"name":"docs","summary":"Print cento CLI docs from the canonical JSON source.","usage":"cento docs [ENTRY] [--json|--path]","flags":[{"name":"--json","summary":"Print the raw JSON document.","usage":"cento docs --json"},{"name":"--path","summary":"Print the path to the canonical JSON document.","usage":"cento docs --path"}],"examples":["cento docs","cento docs conf","cento docs --json","cento docs --path"]},{"name":"tools","summary":"List registered cento tools from the tool registry.","usage":"cento tools","flags":[],"examples":["cento tools"]},{"name":"aliases","summary":"List configured user aliases.","usage":"cento aliases","flags":[],"examples":["cento aliases"]},{"name":"conf","summary":"Open or print the cento alias config file.","usage":"cento conf [--path]","flags":[{"name":"--path","summary":"Print the config path instead of opening it in an editor.","usage":"cento conf --path"}],"examples":["cento conf","cento conf --path"]},{"name":"completion","summary":"Print shell completion for a supported shell.","usage":"cento completion zsh","flags":[],"examples":["cento completion zsh"]},{"name":"install","summary":"Install cento shell and terminal integration.","usage":"cento install [zsh|tmux|terminal|all]","flags":[],"examples":["cento install","cento install zsh","cento install tmux","cento install terminal"],"details":["`zsh` installs completion under `~/.config/cento/completions/_cento`, writes `~/.config/cento/init.zsh`, injects one guarded source block into `~/.zshrc`, and adds a right-prompt segment like `[cento:linux:host]`.","`tmux` writes `~/.config/cento/tmux.conf`, injects one guarded source block into `~/.tmux.conf`, and reloads tmux when a server is running.","`terminal` and `all` install the Zsh/Oh My Zsh prompt and completion path only. Run `cento install tmux` explicitly if you want tmux status integration."]},{"name":"tmux","summary":"Manage the cento tmux status badge integration.","usage":"cento tmux [badge|install|status|docs]","flags":[],"examples":["cento tmux badge","cento tmux status","cento tmux install","cento tmux docs"],"details":["`cento tmux badge` prints the short label rendered in tmux status-left.","Set `CENTO_TMUX_BADGE` to override the label and `CENTO_TMUX_BADGE_HOST=1` to append the short hostname.","The generated fragment preserves the current status-left as `@cento_status_left_base` and prepends the Cento badge. Tmux integration is opt-in via `cento install tmux`."]},{"name":"run","summary":"Run a registered tool by id, or create a fast/standard/thorough execution contract with optional one-local-builder patch collection for owned-path tasks.","usage":"cento run TOOL [args...] | cento run fast|standard|thorough --task TEXT [--write PATH] [--local-builder [RUNTIME] --apply]","flags":[{"name":"--mode fast|standard|thorough","summary":"Create an execution-mode contract without using the positional mode form.","usage":"cento run --mode fast --task \"Fix app docs page\" --write apps/foo/index.html"},{"name":"--task TEXT","summary":"Operator task statement for execution-mode contracts.","usage":"cento run fast --task \"Fix app docs page\""},{"name":"--write PATH","summary":"Owned writable path for the generated contract. Repeatable.","usage":"cento run fast --task \"Fix app docs page\" --write apps/foo/index.html"},{"name":"--local-builder [RUNTIME]","summary":"Run one local builder runtime, defaulting to the deterministic fixture runtime when no value is supplied.","usage":"cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --local-builder fixture"},{"name":"--fixture-case valid|unowned|protected|delete|lockfile|binary","summary":"Select the deterministic fixture worker case.","usage":"cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --local-builder fixture --fixture-case valid"},{"name":"--builder-command TEXT","summary":"Unsafe raw command template used when --local-builder command is selected; prefer --runtime-profile.","usage":"cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --local-builder command --builder-command \"codex exec --prompt-file {prompt}\" --allow-unsafe-command"},{"name":"--runtime-profile NAME","summary":"Run one local builder from a named profile in .cento/runtimes.yaml.","usage":"cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --runtime-profile codex-fast --apply"},{"name":"--apply","summary":"Apply the accepted local-builder patch bundle to the operator worktree.","usage":"cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --local-builder fixture --fixture-case valid --apply"}],"examples":["cento run scan --query \"mcp\"","cento run crm docs","cento run factory status workspace/runs/factory/factory-planning-e2e","cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo","cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo --runtime-profile codex-fast --apply --validation smoke --commit none","cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo --local-builder fixture --fixture-case valid --apply --validation smoke --commit none","cento run --mode standard --task \"Polish Kanji docs\" --write templates/agent-work-app/index.html --validation focused"],"details":["`cento run fast --task ... --write PATH` creates the execution contract and an implicit `.cento/builds/<id>/` manifest and Builder prompt. Without `--local-builder` or `--runtime-profile`, the integration receipt remains pending. With `--runtime-profile codex-fast --apply` or `--local-builder fixture --fixture-case valid --apply`, Cento launches one isolated local builder, collects a patch bundle, dry-runs integration, applies the accepted patch, runs smoke validation, and writes Taskstream evidence."]},{"name":"build","summary":"Registered tool route for manifest-owned local build packages, local worker patch collection, worker artifact checks, patch bundles, dry-run integration, and safe apply.","usage":"cento build <init|check|prompt|worker|artifact|bundle|integrate|apply|receipt> [args...]","flags":[],"examples":["cento build init --task \"Fixture docs page patch\" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture","cento build check tests/fixtures/cento_build/manifest.valid.json","cento build prompt tests/fixtures/cento_build/manifest.valid.json","cento build artifact check tests/fixtures/cento_build/worker_artifact.valid.json","cento build worker run .cento/builds/<id>/manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180","cento build worker run .cento/builds/<id>/manifest.json --worker builder_1 --runtime-profile codex-fast --worktree","cento runtime check codex-fast","cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff","cento build integrate .cento/builds/<id>/manifest.json --bundle .cento/builds/<id>/workers/builder_1/patch_bundle.json --worktree --dry-run","cento build apply .cento/builds/<id>/manifest.json --bundle .cento/builds/<id>/workers/builder_1/patch_bundle.json --from-receipt .cento/builds/<id>/integration_receipt.json","cento build receipt .cento/builds/build_fixture_docs_page_001"],"details":["This command is routed through data/tools.json and scripts/cento_build.py.","Build v1.2 is local-only and deterministic. It creates manifests and Builder prompts, can run one fixture/local builder in an isolated worktree, checks worker artifacts, synthesizes patch bundles, rejects raw patch integration outside dev mode, rejects dirty owned paths by default, rejects unowned/protected/hostile patch paths, supports hardened runtime profiles, dry-runs in an isolated worktree, applies only from accepted integration receipts, writes validation/integration/apply/evidence receipts, and leaves cloud workers, API calls, schedulers, PRs, and automatic model patch generation for later Factory layers."]},{"name":"runtime","summary":"Registered tool route for local builder runtime profile inspection.","usage":"cento runtime <list|check> [args...]","flags":[],"examples":["cento runtime list","cento runtime check codex-fast","cento runtime check codex-fast --json","cento runtime check python-fixture --require-executable"],"details":["Runtime profiles live in `.cento/runtimes.yaml` and define argv-array command runtimes or deterministic fixture profiles.","`cento runtime check` validates the profile shape and reports executable availability without launching a worker."]},{"name":"workset","summary":"Registered tool route for local N-worker worksets and structured API worker artifacts.","usage":"cento workset <check|run|execute|materialize-artifact> [args...]","flags":[],"examples":["cento workset check tests/fixtures/cento_workset/workset.valid.json","cento workset check tests/fixtures/cento_workset/workset.execute.api.json --runtime api-openai","cento workset check tests/fixtures/cento_workset/workset.overlap.json","cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke","cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke","cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke","cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke","cento workset materialize-artifact .cento/worksets/<run_id>/workers/<worker_id>/artifact.json"],"details":["Workset v1 requires exclusive write_paths for each task. No shared files, overlapping paths, or glob write paths are accepted.","Plain `cento workset check WORKSET` rejects missing write paths. API-worker-created file plans must declare `--runtime api-openai` or `--allow-creates`.","Workers run in parallel only for patch or structured artifact collection. Integration and apply are always sequential.","OpenAI API workers use Responses API structured outputs and never mutate repo files directly.","API worker budgets have a target and hard max; budget-blocked workers still write cost receipts.","Simple depends_on gates are supported; a task dispatches only after dependencies are completed and applied."]},{"name":"factory","summary":"Registered tool route for the no-model Cento Factory planning, dispatch dry-run, and Safe Integrator workflow.","usage":"cento factory <intake|plan|materialize|create-issues|preflight|queue|lease|dispatch|collect|validate|integrate|validate-integrated|release-candidate|sync-taskstream|release|render-hub|status|autopilot|autopilot-status|autopilot-render|runtime> [args...]","flags":[],"examples":["cento factory intake \"develop me a career consulting module\" --dry-run --out workspace/runs/factory/factory-planning-e2e","cento factory plan workspace/runs/factory/factory-planning-e2e --no-model","cento factory materialize workspace/runs/factory/factory-planning-e2e","cento factory queue workspace/runs/factory/factory-planning-e2e","cento factory lease workspace/runs/factory/factory-planning-e2e --task crm-schema-extension --dry-run","cento factory dispatch workspace/runs/factory/factory-planning-e2e --lane builder --max 4 --dry-run","cento factory collect workspace/runs/factory/factory-planning-e2e","cento factory validate workspace/runs/factory/factory-planning-e2e","cento factory integrate workspace/runs/factory/factory-planning-e2e --dry-run","cento factory integrate factory-integration-e2e --plan","cento factory integrate factory-integration-e2e --prepare-branch --branch factory/factory-integration-e2e/integration","cento factory integrate factory-integration-e2e --apply --validate-each --limit 3","cento factory validate-integrated factory-integration-e2e","cento factory release-candidate factory-integration-e2e","cento factory sync-taskstream factory-integration-e2e --dry-run","cento factory release workspace/runs/factory/factory-planning-e2e --json","cento factory render-hub workspace/runs/factory/factory-planning-e2e","cento factory autopilot factory-autopilot-runtime-e2e --dry-run --cycles 5","cento factory autopilot-status factory-autopilot-runtime-e2e --json","cento factory autopilot-render factory-autopilot-runtime-e2e","cento factory runtime list --json","cento factory runtime prepare factory-runtime-adapters-e2e --task factory-runtime-task-01 --runtime noop --dry-run","cento factory runtime launch factory-runtime-adapters-e2e --task factory-runtime-task-01 --runtime noop --dry-run","cento factory runtime status factory-runtime-adapters-e2e --task factory-runtime-task-01 --json","cento factory runtime collect factory-runtime-adapters-e2e --task factory-runtime-task-01","cento factory runtime cancel factory-runtime-adapters-e2e --task factory-runtime-task-01 --dry-run"],"details":["This command is routed through data/tools.json and scripts/factory.py.","Factory defaults to deterministic no-model planning, queueing, lease simulation, dry-run dispatch, patch collection, Safe Integrator branch/apply/validate gates, rollback metadata, merge readiness, release evidence, Autopilot dry-run control cycles, and runtime adapter contracts. Live Taskstream creation requires --apply, while integration Taskstream sync is a dry-run preview by default."]},{"name":"object-storage","summary":"Registered tool route for Oracle Object Storage dummy uploads and Cento image mirroring.","usage":"cento object-storage <status|ensure-bucket|put-dummy|e2e|plan-images|upload-images|verify-images> [args...]","flags":[{"name":"--bucket","summary":"OCI Object Storage bucket name. Defaults to CENTO_OBJECT_STORAGE_BUCKET.","usage":"cento object-storage put-dummy --bucket my-bucket"},{"name":"--namespace","summary":"OCI Object Storage namespace. Defaults to CENTO_OBJECT_STORAGE_NAMESPACE or OCI CLI auto-discovery.","usage":"cento object-storage put-dummy --namespace mynamespace"},{"name":"--region","summary":"OCI region for Object Storage calls, for example us-ashburn-1.","usage":"cento object-storage e2e --live --region us-ashburn-1"},{"name":"--name","summary":"Bucket name for ensure-bucket.","usage":"cento object-storage ensure-bucket --name cento-images-standard"},{"name":"--dry-run","summary":"Do not call OCI; copy the dummy file into the run-scoped uploaded directory.","usage":"cento object-storage put-dummy --dry-run"},{"name":"--live","summary":"Run the e2e through the live OCI CLI upload path.","usage":"cento object-storage e2e --live --bucket my-bucket"},{"name":"--json","summary":"Print machine-readable JSON.","usage":"cento object-storage e2e --json"},{"name":"--manifest","summary":"Image migration manifest or upload receipt path.","usage":"cento object-storage upload-images --manifest workspace/runs/object-storage/<run-id>/manifest.json"},{"name":"--sample","summary":"Number of unique uploaded image objects to verify; 0 means all.","usage":"cento object-storage verify-images --manifest workspace/runs/object-storage/<run-id>/upload-receipt.json --sample 10"}],"examples":["cento object-storage status","cento object-storage status --probe --json","cento object-storage ensure-bucket --name cento-images-standard --region us-ashburn-1 --namespace mynamespace --json","cento object-storage put-dummy --dry-run --json","cento object-storage put-dummy --region us-ashburn-1 --bucket my-bucket --namespace mynamespace --json","cento object-storage e2e --json","cento object-storage e2e --live --region us-ashburn-1 --bucket my-bucket --namespace mynamespace --json","cento object-storage plan-images --root workspace/runs --bucket cento-images-standard --namespace mynamespace --region us-ashburn-1 --json","cento object-storage upload-images --manifest workspace/runs/object-storage/<run-id>/manifest.json --live --json","cento object-storage verify-images --manifest workspace/runs/object-storage/<run-id>/upload-receipt.json --sample 10 --json"],"details":["This command is routed through data/tools.json and scripts/object_storage.py.","The MVP writes workspace/runs/object-storage/<run-id>/dummy.txt, records receipt.json and summary.md, and uploads exactly one text object through `oci os object put` when live mode is configured.","Image migration writes a mirror-only manifest for workspace run images, blocks sensitive-looking paths, uploads content-addressed objects, and verifies downloads by sha256.","Dry-run image upload copies files under the run-scoped uploaded directory; live mode requires a private Standard OCI bucket.","Human runbook: docs/oci-image-migration.html; Markdown source: docs/oci-image-migration.md"]},{"name":"storage","summary":"Registered tool route for the no-delete Cento artifact catalog and retention planner.","usage":"cento storage <scan|catalog|plan|query|report|pressure|verify|normalize|compress|snapshot-db|restore-test> [args...]","flags":[],"examples":["cento storage scan --root workspace/runs --db workspace/storage/catalog.sqlite","cento storage plan --dry-run","cento storage query --largest --limit 20","cento storage query --class screenshot_raw","cento storage pressure --json","cento storage normalize screenshots --dry-run","cento storage compress logs --dry-run","cento storage snapshot-db --path workspace/storage/catalog.sqlite --out workspace/storage/db-snapshots/catalog-snapshot.db","cento storage restore-test --sample 10","cento storage verify --all","cento storage report --out workspace/storage/reports/storage-summary.md"],"details":["This command is routed through data/tools.json and scripts/storage.py.","Storage v1 never deletes artifacts and never uploads to cloud. It catalogs, hashes, classifies, plans lifecycle actions, verifies hashes, and renders operator reports so Factory and Autopilot can scale without artifact chaos."]}]}; const kindIcon = { shell: 'sh', python: 'py', go: 'go' }; diff --git a/docs/oci-image-migration.html b/docs/oci-image-migration.html new file mode 100644 index 0000000..874f8aa --- /dev/null +++ b/docs/oci-image-migration.html @@ -0,0 +1,732 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Cento OCI Image Migration + + + +
+ + +
+
+
+ Completed live mirror + OCI Standard + Private bucket + No reclaim yet +
+

OCI Image Migration

+

Cento now has a conservative Object Storage path for mirroring run images into a private Oracle Cloud Infrastructure bucket, with receipts and SHA-256 restore verification.

+ +
+ +
+
+

Current Cloud State

+
+
Bucketcento-images-standard
+
Namespaceid4bktw2wcnn
+
Regionus-ashburn-1
+
Object prefixsha256 CAS
+
+
+
    +
  • Storage tierStandard Object Storage, not Archive.
  • +
  • Public accessNoPublicAccess.
  • +
  • VersioningDisabled, so accidental version growth is not enabled.
  • +
  • ReplicationDisabled, so writes are not copied into another region.
  • +
  • Object eventsDisabled.
  • +
  • Object namescento/images/v1/objects/sha256/<first2>/<sha256>/<filename>
  • +
+
+
+ +
+

Done Now

+
+
+

The completed migration copied images under workspace/runs into OCI by content hash, verified a restore sample, and left local originals unchanged.

+
+
Planned rows452
+
Unique objects352
+
Duplicate rows100
+
Blocked rows0
+
Uploaded bytes361,679,317
+
Verified sample10 objects
+
+
+ +
+
+ +
+

Command Surface

+
+
+

Check OCI auth and namespace

+
cento object-storage status --probe --region us-ashburn-1 --json
+
+
+

Create or verify the private bucket

+
cento object-storage ensure-bucket \
+  --name cento-images-standard \
+  --namespace id4bktw2wcnn \
+  --region us-ashburn-1 \
+  --json
+
+
+

Plan the image mirror

+
cento object-storage plan-images \
+  --root workspace/runs \
+  --bucket cento-images-standard \
+  --namespace id4bktw2wcnn \
+  --region us-ashburn-1 \
+  --json
+
+
+

Upload planned unique objects

+
cento object-storage upload-images \
+  --manifest workspace/runs/object-storage/<run-id>/manifest.json \
+  --bucket cento-images-standard \
+  --namespace id4bktw2wcnn \
+  --region us-ashburn-1 \
+  --live \
+  --json
+
+
+

Verify uploaded objects

+
cento object-storage verify-images \
+  --manifest workspace/runs/object-storage/<run-id>/upload-receipt.json \
+  --sample 10 \
+  --json
+
+
+
+ +
+

Migration Safety Contract

+
+
+

Scanner scope

+
    +
  • Scans image-like files only.
  • +
  • Supports .png, .jpg, .jpeg, .gif, .webp, .svg, and .xwd.
  • +
  • Skips .git, virtualenvs, __pycache__, and node_modules.
  • +
+
+
+

Leak controls

+
    +
  • Blocks sensitive-looking paths containing token, secret, .env, .pem, or key4.db.
  • +
  • Uses a private bucket by default.
  • +
  • Does not require pre-authenticated request URLs.
  • +
+
+
+

Local-file contract

+
    +
  • Never deletes source images.
  • +
  • Never truncates source images.
  • +
  • Never replaces source images.
  • +
+
+
+
+ +
+

Evidence And Artifacts

+

The live run evidence is stored under workspace/runs/object-storage/image-migration-20260505T051846Z/.

+ +
+ Artifact links assume the Cento Console server +

When this page is opened through the console artifact route, the evidence links resolve through /api/artifacts. When browsing files directly, use the paths shown above.

+
+
+ +
+

Cost And Risk

+
+
+

Estimated current storage bill

+

The mirrored unique object bodies total 361,679,317 bytes. At current size, Standard Object Storage is estimated around $0.0086 per month before tiny request and retrieval effects.

+

The practical bill risk is not this migration. The practical bill risk is future unbounded upload scope, versioning, replication, lifecycle drift, broad public links, or repeated uploads without idempotency checks.

+
+
+ Bill explosion triggers +
    +
  • Pointing the scan at broad home directories instead of workspace/runs.
  • +
  • Enabling versioning without lifecycle cleanup.
  • +
  • Enabling cross-region replication.
  • +
  • Re-uploading the same bytes under changing object keys.
  • +
  • Adding video, model, database, or archive artifacts without explicit budgets.
  • +
+
+
+
+ +
+

Next Implementation Steps

+
    +
  1. Resume-safe uploads. Write partial receipts after each upload, add upload-images --resume, and skip already uploaded matching hashes.
  2. +
  3. Remote object metadata. Store hash, source path, artifact class, and migration run id on each OCI object.
  4. +
  5. Idempotent re-runs. Check existing objects before upload and fail closed on mismatched metadata.
  6. +
  7. Storage catalog integration. Record OCI locations in workspace/storage/catalog.sqlite using artifact_locations.
  8. +
  9. Restore commands. Add explicit restore paths that download, verify SHA-256, and avoid overwrites by default.
  10. +
  11. Reclaim planning only. Produce future reclaim plans and restore stubs, but do not remove local files yet.
  12. +
+
+ +
+

AI Handoff

+
+

Next agent should read:

+
    +
  • docs/oci-image-migration.md
  • +
  • scripts/object_storage.py
  • +
  • tests/test_object_storage.py
  • +
  • workspace/runs/object-storage/image-migration-20260505T051846Z/upload-receipt.json
  • +
  • workspace/runs/object-storage/image-migration-20260505T051846Z/verify-receipt.json
  • +
+
Recommended next task:
+Implement upload-images --resume with partial receipts and tests.
+
+Validation:
+python3 -m pytest tests/test_object_storage.py -q
+python3 -m py_compile scripts/object_storage.py
+zsh -n scripts/completion/_cento
+make check
+
+
+
+
+ Generated for local use in the Cento repo. Source: docs/oci-image-migration.md. +
+
+
+ + diff --git a/docs/oci-image-migration.md b/docs/oci-image-migration.md new file mode 100644 index 0000000..a653534 --- /dev/null +++ b/docs/oci-image-migration.md @@ -0,0 +1,378 @@ +# OCI Image Migration + +Readable HTML guide: [`docs/oci-image-migration.html`](./oci-image-migration.html). + +This document is the operator and agent handoff for Cento's mirror-only image migration to Oracle Cloud Infrastructure Object Storage. + +The current migration copies Cento run images to OCI Standard Object Storage, verifies restore samples by SHA-256, and leaves all local files unchanged. It is not a disk-reclaim flow yet. + +## Current Cloud State + +- Bucket: `cento-images-standard` +- Namespace: `id4bktw2wcnn` +- Region: `us-ashburn-1` +- Storage tier: `Standard` +- Public access: `NoPublicAccess` +- Versioning: `Disabled` +- Replication: `Disabled` +- Object events: `Disabled` +- Object prefix: `cento/images/v1/objects/sha256/` + +Current live mirror evidence: + +```text +workspace/runs/object-storage/image-migration-20260505T051846Z/ + manifest.json + summary.md + upload-receipt.json + upload-summary.md + verify-receipt.json +``` + +The completed run planned `452` image rows under `workspace/runs`, deduped them to `352` unique OCI objects, uploaded `361,679,317` bytes, and verified a 10-object download sample. Estimated Standard storage cost for the unique object bodies is about `$0.0086/month`. + +## Command Surface + +Verify OCI auth and namespace in the working region: + +```bash +cento object-storage status --probe --region us-ashburn-1 --json +``` + +Create or verify the private Standard image bucket: + +```bash +cento object-storage ensure-bucket \ + --name cento-images-standard \ + --namespace id4bktw2wcnn \ + --region us-ashburn-1 \ + --json +``` + +Plan a mirror-only image migration: + +```bash +cento object-storage plan-images \ + --root workspace/runs \ + --bucket cento-images-standard \ + --namespace id4bktw2wcnn \ + --region us-ashburn-1 \ + --json +``` + +Upload the planned unique image objects: + +```bash +cento object-storage upload-images \ + --manifest workspace/runs/object-storage//manifest.json \ + --bucket cento-images-standard \ + --namespace id4bktw2wcnn \ + --region us-ashburn-1 \ + --live \ + --json +``` + +Verify uploaded objects by downloading a sample and checking SHA-256: + +```bash +cento object-storage verify-images \ + --manifest workspace/runs/object-storage//upload-receipt.json \ + --sample 10 \ + --json +``` + +## Migration Contract + +The image mirror is intentionally conservative. + +- It scans only image-like files under the selected root. +- Supported suffixes are `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, and `.xwd`. +- It skips `.git`, `.venv`, `venv`, `__pycache__`, and `node_modules`. +- It blocks sensitive-looking paths containing `token`, `secret`, `.env`, `.pem`, or `key4.db`. +- It dedupes by SHA-256 before upload. +- It uploads content-addressed object bodies. +- It never deletes, truncates, rewrites, or replaces local files. + +Object names are deterministic: + +```text +cento/images/v1/objects/sha256/// +``` + +The manifest keeps one row per source image path. Duplicate local files point at the same content-addressed object body but remain distinct manifest rows. + +## Receipts + +`manifest.json` is the planned work: + +- source path +- size +- SHA-256 +- extension +- content type +- artifact class +- sensitivity +- object name and URI +- planned upload role: `primary`, `duplicate`, or `blocked` + +`upload-receipt.json` records the executed upload: + +- source manifest +- bucket, namespace, and region +- uploaded object count +- failed object count +- dedupe count +- blocked row count +- per-row upload status + +`verify-receipt.json` records restore validation: + +- source upload receipt +- verification sample size +- downloaded file paths +- OCI return codes +- expected SHA-256 +- per-object verification result + +## Safety And Risk + +The current bucket posture is private and low-cost: + +- Standard Object Storage, not Archive. +- No public bucket access. +- No pre-authenticated requests are required. +- No versioning, so accidental duplicate version growth is not enabled. +- No replication, so writes are not copied to another region. + +Bill explosion risk is low at current size. The main risks are future unbounded uploads, public access changes, broad pre-authenticated URLs, enabling versioning without lifecycle controls, or adding a disk-reclaim command before restore testing is mature. + +Data leak risk is controlled by private bucket access plus filename/path filtering, but the filter is not a substitute for review. Do not upload secrets, browser profile credential stores, `.env` files, private keys, API tokens, or client-sensitive records. + +## Validation Checklist + +Run this before claiming the migration path is healthy: + +```bash +python3 -m pytest tests/test_object_storage.py -q +python3 -m py_compile scripts/object_storage.py +python3 -m json.tool data/tools.json >/tmp/cento-tools-json-check.txt +python3 -m json.tool data/cento-cli.json >/tmp/cento-cli-json-check.txt +zsh -n scripts/completion/_cento +make check +``` + +Run this against OCI after an upload: + +```bash +oci os bucket get \ + --region us-ashburn-1 \ + --namespace-name id4bktw2wcnn \ + --bucket-name cento-images-standard \ + --query 'data.{name:name,storageTier:"storage-tier",publicAccessType:"public-access-type",versioning:versioning,replicationEnabled:"replication-enabled"}' \ + --output json + +oci os object list \ + --region us-ashburn-1 \ + --namespace-name id4bktw2wcnn \ + --bucket-name cento-images-standard \ + --prefix cento/images/v1/objects/sha256/ \ + --all \ + --output json +``` + +Expected bucket posture: + +```json +{ + "name": "cento-images-standard", + "publicAccessType": "NoPublicAccess", + "replicationEnabled": false, + "storageTier": "Standard", + "versioning": "Disabled" +} +``` + +## Next Implementation Steps + +### 1. Resume-Safe Uploads + +Add incremental receipts so a long upload can resume safely: + +- write `upload-receipt.partial.json` after every object upload +- add `upload-images --resume` +- skip objects already marked `uploaded` with matching SHA +- keep the final `upload-receipt.json` format stable + +Acceptance criteria: + +- killing an upload mid-run leaves a readable partial receipt +- rerunning with `--resume` completes without re-uploading successful objects +- duplicate SHA rows still point to the same primary object + +### 2. Remote Object Metadata + +Write OCI object metadata during upload: + +- `cento-sha256` +- `cento-source-path` +- `cento-artifact-class` +- `cento-migration-run-id` + +Add metadata-aware verification: + +- `verify-images --metadata-first` +- compare remote metadata before downloading +- keep download verification available as the strongest restore proof + +Acceptance criteria: + +- uploaded objects expose the expected metadata through OCI CLI +- metadata mismatch fails verification +- sample download still SHA-verifies + +### 3. Idempotent Re-Runs + +Before uploading a primary object, check whether the target object already exists. + +Desired behavior: + +- if object exists with matching metadata, mark `already_uploaded` +- if object exists without metadata, require download verification or overwrite only with `--force` +- if object exists with mismatched hash metadata, fail closed + +Acceptance criteria: + +- rerunning upload on the same manifest produces zero failed objects +- no accidental duplicate object keys are created +- no existing object is overwritten unless explicitly forced + +### 4. Storage Catalog Integration + +Connect OCI locations back into `workspace/storage/catalog.sqlite`. + +Use the existing `artifact_locations` table: + +```text +artifact_id +location_type = oci_object +uri = oci://id4bktw2wcnn/cento-images-standard/... +verified_at +restore_tested_at +``` + +Update `scripts/storage.py report` to show: + +- local-only image bytes +- OCI-mirrored image bytes +- verified mirrored image count +- restore-tested image count +- unmirrored large images + +Acceptance criteria: + +- `storage.py report` includes OCI mirror status +- mirrored artifacts can be queried by location type +- catalog integrity remains `ok` + +### 5. Restore Commands + +Add explicit restore commands before any disk reclaim work: + +```bash +cento object-storage restore-image \ + --source-path workspace/runs/.../image.png \ + --manifest workspace/runs/object-storage//upload-receipt.json \ + --out workspace/restores/image-restore- + +cento object-storage restore-images \ + --manifest workspace/runs/object-storage//upload-receipt.json \ + --sample 10 \ + --out workspace/restores/image-restore- +``` + +Restore must: + +- download from OCI +- verify SHA-256 +- write `restore-receipt.json` +- avoid overwriting existing files unless `--overwrite` is passed + +Acceptance criteria: + +- one image can be restored by original source path +- a sample batch restore passes SHA verification +- existing local files are protected by default + +### 6. Reclaim Planning Only + +Do not delete local images yet. Add only a future planning command: + +```bash +cento object-storage plan-reclaim-images \ + --manifest workspace/runs/object-storage//upload-receipt.json \ + --json +``` + +Eligibility should require: + +- object uploaded +- object verified +- restore test passed +- local source path still has the same SHA +- run is not active +- path is not sensitive + +The first reclaimable action should be a restore stub, not deletion: + +```text +image.png.oci.json +``` + +Stub fields: + +- original path +- original size +- SHA-256 +- object URI +- migration receipt +- restore command +- migrated timestamp + +Acceptance criteria: + +- unverified objects are never reclaim candidates +- changed local files are rejected +- command writes a plan only and does not remove local files + +## AI Handoff Prompt + +Use this prompt for the next implementation agent: + +```text +You are working in /home/alice/projects/cento. + +Goal: continue the OCI image migration implementation safely. + +Read first: +- docs/oci-image-migration.md +- scripts/object_storage.py +- tests/test_object_storage.py +- workspace/runs/object-storage/image-migration-20260505T051846Z/upload-receipt.json +- workspace/runs/object-storage/image-migration-20260505T051846Z/verify-receipt.json + +Constraints: +- mirror-only remains the default +- never delete or replace local images +- keep the bucket private Standard storage +- use explicit region us-ashburn-1 and namespace id4bktw2wcnn +- update data/tools.json, data/cento-cli.json, docs/tool-index.md, docs/platform-support.md, README.md, and completion if the command surface changes + +Recommended next task: +Implement upload-images --resume with partial receipts and tests. + +Validation: +- python3 -m pytest tests/test_object_storage.py -q +- python3 -m py_compile scripts/object_storage.py +- zsh -n scripts/completion/_cento +- make check +``` diff --git a/docs/parallel-ai-delivery-roadmap.md b/docs/parallel-ai-delivery-roadmap.md new file mode 100644 index 0000000..feae4a7 --- /dev/null +++ b/docs/parallel-ai-delivery-roadmap.md @@ -0,0 +1,85 @@ +# Parallel AI Delivery Roadmap + +Generated by `scripts/proreq_parallel_roadmap.py` from 3 Hard ProReq passes. +Coordination receipt: `workspace/runs/proreq-roadmap/20260504T222833Z/coordination_receipt.json`. + +## Objective + +Build the next Cento delivery layer: one requirements pass decomposes a feature into exclusive workstreams, 10 workers produce structured patch or artifact outputs in parallel, 2-3 integrator/validator lanes converge the results, and deterministic integration decides most outcomes before any extra model review is called. The target operator experience is task completion in 2-3 minutes instead of roughly 10 minutes, with only about $3-5 marginal AI cost for fanout and fallback review. + +## Implemented Coordinator + +The roadmap is now implemented as the Cento-native `parallel-delivery` tool backed by `scripts/parallel_delivery.py`. + +Primary operator commands: + +```bash +cento parallel-delivery plan --json +cento parallel-delivery execute --sleep-seconds 1 --json +cento parallel-delivery execute --live-pro --sleep-seconds 1 --json +cento parallel-delivery demo --json +cento parallel-delivery validate --json +cento parallel-delivery status --json +cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json +``` + +The coordinator writes a run under `workspace/runs/parallel-delivery//` with: + +- `implementation_manifest.json`: 12 VP-level workstream ProReq passes, each with operator prompt and image task. +- `proreq_receipt.json`: completed Hard ProReq run IDs, artifact roots, generated worksets, Pro/image lane status, and workset checks. +- `execution_manifest.json`: high-level integration, validation, fallback, and demo handoff. +- `validation_summary.json`: machine-readable pass/fail checks for plan, receipts, worksets, and demo. +- `demo/demo_receipt.json`: 10-lane fixture workset proof with `max_parallel: 10`, sequential dry-run integration, and no repository mutation. +- `patch-swarm//candidate_index.json`: 100+ candidate patch receipts across Codex Exec, Claude Code, and OpenAI-compatible provider adapters. +- `patch-swarm//safe_integrator_handoff.json`: dedicated integration execution output; no direct main-worktree apply. + +By default the tool uses less compute: live Pro dispatch is disabled unless `--live-pro` is passed. Hard ProReq still emits ChatGPT Pro request manifests and image generation requests, and image execution follows the existing configured image lane. + +Patch Swarm is the next high-parallel layer. It keeps the existing Workset/train/Safe Integrator discipline, but changes the worker strategy from "one patch per task" to "many provider-diverse candidate patches per task, then one deterministic selection lane." See `docs/patch-swarm.md`. + +## ProReq Evidence + +| Pass | Status | Stories | Workset | Pro | Image | +| --- | --- | ---: | --- | --- | --- | +| Architecture Roadmap | completed | 10 | `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/hard-proreq-task-hard-proreq-project-20260504T222833600688Z/parallel_patch_workset.json` | skipped (dispatch-disabled) | failed | +| Integration And Validation Manifests | completed | 10 | `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/hard-proreq-task-hard-proreq-project-20260504T222913747149Z/parallel_patch_workset.json` | skipped (dispatch-disabled) | failed | +| Operator Image And Flow | completed | 10 | `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/hard-proreq-task-hard-proreq-project-20260504T222953898851Z/parallel_patch_workset.json` | skipped (dispatch-disabled) | failed | + +Completed passes: 3/3. Pro response states: skipped. Image response states: failed. + +Operational note: live Pro dispatch was attempted before the final successful coordination run and timed out at the elevated 420-second step limit. The final three-pass run used deterministic ProReq fallback so the roadmap could complete E2E. Each pass still produced an image request; the live image call returned HTTP 403 because the organization is not verified for `gpt-image-2`. + +## Target Architecture + +1. Intake turns operator notes into a strict requirements packet: goal, acceptance checks, read context, owned path candidates, risk limits, budget, and validation mode. +2. Planning creates 8-12 workstreams, defaulting to 10, and rejects overlapping write paths unless the overlap is moved into an explicit serialized integrator task. +3. Worker fanout runs up to 10 structured workers through `cento workset execute`; workers return patch proposals or artifacts and never mutate repo files directly. +4. Integration/validation runs as 2-3 deterministic lanes: patch ownership and apply checks, focused tests/UI or artifact checks, and release evidence/rollback checks. +5. AI fallback is called only for unresolved ambiguity, failed deterministic validation that needs diagnosis, or conflict review, using a compact failure packet and a cheap reviewer profile such as `api-mini-integrator`. +6. Handoff writes one release packet with applied patches, rejected patches, validation receipts, cost receipt, timings, rollback plan, and residual risks. + +## Implementation Roadmap + +M1: Make ProReq output directly executable by the workset layer. The generated 10-story handoff must become a checked `cento.workset.v1` manifest with `max_parallel: 10`, per-task cost estimates, and validation commands. + +M2: Add the integrator/validator pool without changing the worker contract. Start with three deterministic lanes: patch safety, focused validation, and release evidence. Independent failures are quarantined without blocking unrelated accepted patches. + +M3: Add only-if-needed AI review. Clean runs use zero reviewer calls after planning. Conflicted fixtures produce exactly one bounded reviewer artifact, then return to deterministic patch and receipt handling. + +M4: Benchmark speed and cost with 1, 3, 5, and 10 workers. Track wall-clock time, queue delay, integration time, model calls, and estimated cost until medium scoped tasks land in the 2-3 minute and $3-5 marginal range. + +M5: Expose the flow in Dev Pipeline Studio or Factory as `Run Parallel Delivery`: live worker lanes, integrator lanes, deterministic gates, fallback review calls, cost, and evidence receipts in one execution view. + +## Acceptance Metrics + +- 10 worker lanes can run concurrently when write paths are exclusive. +- 2-3 integrator/validator lanes classify clean, failed, and conflicted outputs deterministically. +- Clean runs complete without AI review after initial planning. +- Conflicted runs call AI only with a compact failure packet and a hard budget ceiling. +- Typical end-to-end completion time is 2-3 minutes for medium scoped tasks. +- Marginal fanout and fallback cost stays near $3-5, with a hard stop before budget overrun. +- Every run leaves receipts for worker outputs, integration decisions, validation, rollback, cost, and final handoff. + +## Risks + +Shared-file pressure is the main design risk. The system should not hide shared file edits inside parallel workers; it should emit a serialized integrator task. Validation latency is the second risk, so the next implementation needs narrow validation selection before increasing fanout. The third risk is model drift during fallback review; fallback output remains advisory unless it is converted into the same deterministic patch and receipt contract as worker output. diff --git a/docs/parallel-delivery/patch-bundle-validation.md b/docs/parallel-delivery/patch-bundle-validation.md new file mode 100644 index 0000000..d7cbb3e --- /dev/null +++ b/docs/parallel-delivery/patch-bundle-validation.md @@ -0,0 +1,79 @@ +# Patch Swarm Patch Bundle Collection and Safety Validation + +## Overview + +Patch bundle collection is the local-first handoff between worker outputs and later integration. Workers submit either a `cento.patch_bundle.v1` manifest with a local diff reference or an evidence-only result. Cento validates those outputs against the authoritative lease manifest, writes one receipt per bundle, and writes an aggregate report. This slice does not apply, stage, commit, reset, clean, or integrate patches. + +The implementation lives in `scripts/parallel_delivery_patch_bundles.py` and is exposed through: + +```bash +cento parallel-delivery patch-bundles validate --bundle PATH --lease-manifest PATH --out DIR --base-commit COMMIT --json +cento parallel-delivery patch-bundles collect --bundles-dir DIR --lease-manifest PATH --out DIR --run-id RUN_ID --base-commit COMMIT --json +``` + +## Bundle Schema + +Patch bundles use `schema: cento.patch_bundle.v1` and include `bundle_id`, `task_id`, `worker_id`, `run_id`, `base_commit`, `touched_paths`, `diff_path` or a local `patch_content_ref`, `changed_file_summary`, `validation_commands`, `evidence_files`, `result_status`, and `risk_flags`. + +Evidence-only bundles set `result_status: evidence_only`, leave `touched_paths` empty, and provide safe local evidence file references. They are receipted without requiring a diff. + +## Authoritative Leases + +Worker-provided ownership data is not trusted. The validator reads the task lease from a local lease manifest. The v1 fixture schema is `cento.patch_bundle_leases.v1` with one `tasks` entry per task. Each task declares `allowed_paths`, `protected_paths`, allowed deletes/renames/lockfiles, binary/symlink/submodule policy, and lockfile line limits. + +## Safety Checks + +The validator reuses `cento build` path matching and lockfile helpers where those policies already exist. It validates manifest paths and parsed diff paths and rejects absolute paths, traversal, NUL bytes, Windows drive paths, remote patch refs, edits outside the lease, protected path edits, `.env.mcp` and local secret-looking paths, prohibited symlink/submodule/binary patches, undeclared deletes, unowned renames, broad lockfile changes, secret-looking added patch content, missing or unsafe evidence refs, and base commit mismatches. + +Secret scanning only inspects added patch lines and receipts store redacted detector details, not matched values. + +## Receipts and Reports + +Each bundle writes a deterministic JSON receipt under `receipts/`. Accepted receipts set `validation_status: accepted`; rejected receipts include stable `reason_codes` and redacted `issues`. + +The collector writes: + +- `patch-bundle-report.json` +- `patch-bundle-report.md` +- `validation-summary.txt` +- `receipts/receipt-*.json` + +The report includes accepted/rejected/evidence-only counts, rejection reason counts, receipt paths, run id, base commit, and validator version. + +## Fixture Run + +The deterministic fixture input writer is: + +```bash +python3 scripts/parallel_delivery/patch_bundle_fixture.py \ + --out workspace/runs/parallel-delivery/patch-bundle-fixture \ + --base-commit "$(git rev-parse HEAD)" +``` + +Then collect: + +```bash +cento parallel-delivery patch-bundles collect \ + --run-id patch-bundle-fixture \ + --bundles-dir workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles \ + --lease-manifest workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json \ + --out workspace/runs/parallel-delivery/patch-bundle-fixture \ + --base-commit "$(git rev-parse HEAD)" \ + --json +``` + +The fixture includes one safe patch bundle, one evidence-only bundle, and rejected bundles for outside lease, protected path, `.env.mcp`, traversal, absolute path, symlink, submodule, binary patch, undeclared delete, unowned rename, broad lockfile change, and fake secret-looking added content. + +## Rejection Codes + +Stable reason codes include `missing_required_field`, `invalid_bundle_schema`, `run_id_mismatch`, `base_commit_mismatch`, `missing_task_lease`, `unsafe_path_traversal`, `absolute_path`, `path_outside_lease`, `diff_path_not_declared`, `declared_path_not_in_diff`, `protected_path_edit`, `local_secret_path_edit`, `symlink_patch_prohibited`, `submodule_patch_prohibited`, `binary_patch_prohibited`, `undeclared_delete`, `unowned_rename`, `broad_lockfile_change`, `secret_like_content`, `unsafe_evidence_path`, `missing_evidence_file`, `unsupported_patch_ref`, `worker_validation_missing`, and `worker_validation_failed`. + +## Unsafe Rules + +- Do not copy secrets or `.env.mcp`. +- Do not store OpenAI keys or local secret values. +- Do not trust worker-provided lease data. +- Do not support remote patch refs in this slice. +- Do not claim a bundle is accepted without a receipt. +- Do not apply patches in collection or validation. +- Do not write generated run artifacts outside `workspace/runs/` unless an operator explicitly provides another output directory. diff --git a/docs/parallel-delivery/patch-swarm-artifacts.md b/docs/parallel-delivery/patch-swarm-artifacts.md new file mode 100644 index 0000000..3ac1544 --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-artifacts.md @@ -0,0 +1,247 @@ +# Patch Swarm Artifact Schema + +## Overview + +Patch Swarm artifact schemas define the durable run contract for Parallel Software Delivery. They cover run state, task state, path leases, worker prompts, worker ledgers, patch bundles, validation, integration receipts, release candidate evidence, and the operator entrypoint artifact. + +This contract is schema and evidence only. It does not claim live planning, worker dispatch, patch application, or full integration runtime support. + +## Run Directory Layout + +The canonical schema fixture and future run contract use this root-relative shape: + +```text +workspace/runs/parallel-delivery// + run.json + request.md + context-pack.json + split-plan.json + task-graph.json + path-leases.json + worker-prompts/ + manifest.json + task-0001.md + worker-ledger.jsonl + patch-bundles/ + manifest.json + task-0001.bundle.json + task-0001.patch + integration-plan.json + integration-receipt.json + validation.json + validation-report.md + release-candidate.json + release-notes.md + start-here.md +``` + +The generated fixture lives at `workspace/runs/parallel-delivery/schema-fixture/`. + +## Common Metadata + +Every JSON artifact includes: + +```json +{ + "schema_version": 1, + "artifact_type": "run", + "run_id": "schema-fixture", + "created_at": "2026-01-01T00:00:00Z", + "provenance": { + "producer": "cento.parallel-delivery.artifacts", + "command": "schema-fixture", + "source": "fixture", + "repo": "cento", + "notes": [] + }, + "evidence_pointers": [] +} +``` + +Mutable JSON artifacts also include `updated_at` when their state can change. Markdown artifacts start with a parseable metadata comment: + +```markdown + +``` + +## Versioning and Compatibility + +- `schema_version == 1`: valid. +- `schema_version < 1`: invalid until an explicit compatibility shim exists. +- `schema_version > 1`: invalid by default. +- `schema_version > 1` with `allow_future=True`: allowed for generic/common checks only. +- Unknown extra fields are allowed. +- Missing required fields are fatal. +- Invalid `artifact_type`, state, or transition is fatal. + +## Run States + +Allowed run states: + +```text +request_received +run_created +context_packed +split_planned +task_graph_ready +paths_leased +prompts_emitted +workers_started +patches_collected +validation_started +validation_passed +validation_failed +integration_planned +integration_started +integration_completed +rc_built +rc_validated +completed +failed +aborted +``` + +Terminal states are `completed`, `failed`, and `aborted`. The schema helper enforces known transitions and rejects movement out of terminal states. + +## Task States + +Allowed task states: + +```text +created +context_ready +leased +prompt_emitted +dispatched +patch_submitted +validation_running +validation_passed +validation_failed +queued_for_integration +integrated +rejected +superseded +aborted +``` + +Terminal task states are `integrated`, `rejected`, `superseded`, and `aborted`. + +## Artifact Producer / Consumer Matrix + +| Artifact | Produced by | Consumed by | +| --- | --- | --- | +| `run.json` | `patch-swarm init` / fixture builder | status, validator, release evidence | +| `request.md` | operator / init | context packer, splitter | +| `context-pack.json` | context packer | splitter, prompt emitter | +| `split-plan.json` | factory splitter | task graph builder, lease planner | +| `task-graph.json` | task graph builder | scheduler, integrator | +| `path-leases.json` | workset lease planner | prompt emitter, patch bundle validator | +| `worker-prompts/` | prompt emitter | Codex/worker threads | +| `worker-ledger.jsonl` | dispatcher/collector | status, validation, evidence | +| `patch-bundles/` | workers/collector | validation, integrator | +| `integration-plan.json` | safe integrator planner | safe integrator executor | +| `integration-receipt.json` | safe integrator | release candidate builder | +| `validation.json` | validator/build | release candidate builder, status | +| `validation-report.md` | validator/build | operator, evidence | +| `release-candidate.json` | RC builder | release notes, operator | +| `release-notes.md` | RC builder | operator | +| `start-here.md` | evidence writer | operator | + +## Artifact Schemas + +`run.json` records `request_title`, `state`, `artifact_paths`, `counts`, optional operator metadata, compatibility notes, failure reason, and completion time. + +`context-pack.json` records safe repo metadata only: repo name, default branch, relevant surfaces, dirty-work policy, source refs, constraints, and request reference. + +`split-plan.json` records up to 100 candidate tasks. Every task has a task ID, title, summary, state, acceptance contract, validation commands, owned paths, and read-only paths. + +`task-graph.json` records task nodes and edges. Dependency edges must be acyclic. + +`path-leases.json` records proposed, active, released, conflict, or expired leases. Paths must be relative repo paths and active owned paths must not overlap. + +`worker-prompts/manifest.json` indexes prompt files by task ID, relative path, SHA-256, and creation time. Each prompt Markdown file has a `cento-artifact` metadata comment. + +`worker-ledger.jsonl` records one event per line. Invalid JSON reports the exact line number. + +`patch-bundles/manifest.json` indexes patch bundles. Each bundle records task ID, bundle ID, base ref, changed paths, claimed paths, diff path, tests run, summary, evidence pointers, and manual-review status. Changed paths must be inside the task lease and claimed paths. + +`integration-plan.json` records the integration strategy, queue, rejected entries, validation references, and ordering reasons. + +`integration-receipt.json` records started and completed timestamps, integrated entries, rejected entries, conflicts, strategy, and final state. + +`validation.json` records schema checks, command checks, task checks, overall result, and evidence pointers. + +`release-candidate.json` records RC ID, included tasks, included bundles, validation reference, source integration receipt, state, and evidence pointers. + +`validation-report.md`, `release-notes.md`, and `start-here.md` are human-facing Markdown artifacts with metadata comments and required sections. + +## Evidence Pointers + +Evidence pointers are objects that can include `artifact_type`, `path`, `sha256`, and `description`. Paths must be relative to the run directory or repo context. Absolute paths, parent traversal, `.env.mcp`, and secret-like paths are rejected. + +## Failure States + +Run failure states: + +- `failed`: deterministic validation, schema validation, planning, integration, or release-candidate construction failed. +- `aborted`: operator or safety policy stopped the run. + +Task failure states: + +- `validation_failed`: deterministic validation failed. +- `rejected`: the task is not eligible for integration. +- `superseded`: a newer task or bundle replaces it with evidence. +- `aborted`: operator or safety policy stopped the task. + +Integration final states: + +- `integration_completed` +- `integration_failed` +- `integration_aborted` + +Release candidate states: + +- `rc_built` +- `rc_validated` +- `rc_failed` + +## Validation Commands + +Print the schema summary: + +```bash +python3 scripts/parallel_delivery_artifacts.py print-schema-summary --json +``` + +Write the deterministic fixture: + +```bash +python3 scripts/parallel_delivery_artifacts.py write-fixture \ + --run-dir workspace/runs/parallel-delivery/schema-fixture \ + --run-id schema-fixture \ + --fixed-timestamp 2026-01-01T00:00:00Z +``` + +Validate a run directory: + +```bash +python3 scripts/parallel_delivery_artifacts.py validate-run \ + --run-dir workspace/runs/parallel-delivery/schema-fixture \ + --json +``` + +## Fixture Run + +The fixture is deterministic when `--fixed-timestamp` is provided. It includes all required schema artifacts, two worker prompts, one patch bundle, an integration plan and receipt, validation evidence, release notes, and `start-here.md`. + +The fixture does not apply patches or dispatch live workers. + +## Unsafe Artifact Rules + +- Do not include secrets. +- Do not copy `.env.mcp`. +- Do not store OpenAI keys or local secret values. +- Do not store absolute secret paths. +- Do not directly mutate Taskstream, Redmine, or story database state. +- Do not claim validation passed without validation evidence. +- Do not write generated run artifacts outside `workspace/runs/`. diff --git a/docs/parallel-delivery/patch-swarm-codex-worker-packets.md b/docs/parallel-delivery/patch-swarm-codex-worker-packets.md new file mode 100644 index 0000000..a6ea212 --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-codex-worker-packets.md @@ -0,0 +1,137 @@ +# Patch Swarm Codex Worker Packets + +## Overview + +Patch Swarm Codex worker packets are local Markdown instructions for copy/paste Codex execution. They are generated from `split-plan.json`, `task-graph.json`, and `path-leases.json`; they do not call Codex, OpenAI APIs, ChatGPT Pro, MCP, Taskstream, Redmine, worker pools, or patch application paths. + +## Operator Copy/Paste Flow + +Generate the packet bundle, open `codex-packet-index.md`, and copy exactly one packet into one Codex thread. Each worker leaves a patch bundle, diff, handoff note, and evidence under the run directory. Integration remains a later Safe Integrator step. + +## Inputs + +- `request.md` +- `split-plan.json` +- `task-graph.json` +- `path-leases.json` + +`path-leases.json` is the source of truth for owned write paths, read-only paths, guarded paths, protected paths, dirty owned paths, manual review flags, dependency gates, and parallel groups. + +## Outputs + +- `codex-packet-bundle.json` +- `codex-packet-index.json` +- `codex-packet-index.md` +- `packets/` for fixture runs or `codex-packets/` for real runs +- `patch-bundles/README.md` +- `handoffs/README.md` +- `packet-validation.json` +- `packet-validation-report.md` +- `start-here.md` + +## Packet Bundle Layout + +Fixture evidence is written under `workspace/runs/parallel-delivery/codex-packets-fixture/`. Real run packets stay in the selected run directory and use the same index and validation artifacts. + +## Worker Packet Required Sections + +Every packet includes: + +- `## Thread Title` +- `## Task ID` +- `## Mission` +- `## Discovery Commands` +- `## Owned Write Paths` +- `## Read-Only Paths` +- `## Prohibited Paths` +- `## Implementation Steps` +- `## Expected Files Changed` +- `## Tests And Validation` +- `## Evidence Path` +- `## Patch Bundle Output Instructions` +- `## Handoff Note Format` +- `## Failure / Blocker Protocol` +- `## Safety Rules` +- `## Acceptance Criteria` + +## Lane-Specific Guidance + +Builder packets emphasize small bounded implementation and patch bundles. Validator packets emphasize tests, fixtures, negative cases, and evidence. Docs-evidence packets emphasize operator-facing docs and run evidence. Coordinator packets emphasize manifest, schema, CLI, registry, and docs consistency without broad rewrites. Integrator packets emphasize planning or validation only unless a lease explicitly permits a safe apply action. Human-handoff packets are non-mutating. + +## Path Lease Enforcement + +Packets instruct workers to edit only Owned Write Paths and inspect Read-Only Paths without modification. If a required change appears outside the lease, the worker must stop and write `workers//handoff.md`. + +## Prohibited Paths + +Every packet includes secret and lease guards including `.env`, `.env.*`, `.env.mcp`, `.git/**`, key/certificate patterns, read-only paths, other tasks' owned paths, and paths outside the task lease. + +## Patch Bundle Output + +Every worker writes: + +```text +workers//handoff.md +workers//evidence/ +patch-bundles/.patch-bundle.json +patch-bundles/.diff +``` + +The patch bundle records run ID, task ID, base ref, worker ID, claimed paths, changed paths, diff path, summary, tests run, evidence files, handoff note, risks, and manual review status. + +## Handoff Note Format + +Handoff notes use: + +```markdown +# Codex Worker Handoff + +## Task ID +## Status +## Summary +## Files Changed +## Validation Run +## Evidence Files +## Blockers +## Risks +## Suggested Next Action +``` + +## Failure and Blocker Protocol + +Workers stop and write a handoff when required edits are outside owned paths, dirty work would be overwritten, validation needs missing secrets or external services, direct Taskstream/Redmine database writes are required, acceptance criteria conflict, dependencies are missing, or protected paths need changes. + +## CLI Examples + +```bash +cento parallel-delivery patch-swarm worker-packets \ + --run-dir workspace/runs/parallel-delivery/codex-packets-fixture \ + --run-id codex-packets-fixture \ + --fixture \ + --count 10 \ + --json +``` + +Script fallback: + +```bash +python3 scripts/parallel_delivery_codex_packets.py write-fixture --run-dir workspace/runs/parallel-delivery/codex-packets-fixture --run-id codex-packets-fixture --count 10 --json +python3 scripts/parallel_delivery_codex_packets.py generate --run-dir workspace/runs/parallel-delivery/codex-packets-fixture --count 10 --json +python3 scripts/parallel_delivery_codex_packets.py validate-bundle --run-dir workspace/runs/parallel-delivery/codex-packets-fixture --json +python3 scripts/parallel_delivery_codex_packets.py print-policy --json +``` + +## Fixture Run + +The deterministic fixture creates 10 tasks: two builder, two validator, two docs-evidence, two coordinator, and two integrator packets with non-overlapping owned paths and shared read-only context. + +## Validation Commands + +```bash +python3 scripts/parallel_delivery_codex_packets.py validate-bundle --run-dir workspace/runs/parallel-delivery/codex-packets-fixture --json +pytest -q tests/test_parallel_delivery_codex_worker_packets.py +``` + +## Unsafe Packet Rules + +Packets are unsafe if they omit path leases, encourage edits outside owned paths, contain secret-like values, require live services, require direct Taskstream/Redmine database writes, claim validation without evidence, or apply patches in this generation slice. diff --git a/docs/parallel-delivery/patch-swarm-console.md b/docs/parallel-delivery/patch-swarm-console.md new file mode 100644 index 0000000..6e8809d --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-console.md @@ -0,0 +1,81 @@ +# Patch Swarm Console Status + +Patch Swarm console status is an artifact-backed operator view for an existing run. It does not create a database. It reads the run directory, writes stable `console-data.json`, and can export a no-build static `start-here.html` hub with relative evidence links. + +## Render A Console Hub + +```bash +cento parallel-delivery patch-swarm status \ + --run-dir workspace/runs/parallel-delivery/e2e-fixture/fixture-100-agents \ + --write-html \ + --json +``` + +The command writes: + +```text +/console-data.json +/start-here.html +/link-check.json +``` + +`--json` prints a compact machine-readable summary with the run id, result, candidate count, workers, bundle buckets, integration, validation, release candidate, next action, and generated artifact paths. + +## Generate A Fixture Run + +```bash +cento parallel-delivery patch-swarm e2e \ + --candidate-target 25 \ + --max-parallel-agents 5 \ + --fixture \ + --run-id fixture-console-25 \ + --output-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 \ + --json +``` + +Then render the hub: + +```bash +cento parallel-delivery patch-swarm status \ + --run-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 \ + --write-html \ + --json +``` + +Open `workspace/runs/parallel-delivery/console-fixture/fixture-console-25/start-here.html` in a browser, or use the Cento Console route: + +```text +/patch-swarm/console?run_dir=workspace/runs/parallel-delivery/console-fixture/fixture-console-25 +``` + +Existing product Patch Swarm runs also expose a `Status console` link from the run detail evidence row. + +## Source Artifacts + +The console reads whichever supported artifact shape exists in the run: + +- `validation-summary.json` or `validation_summary.json` +- `validation-report.md` +- `split-plan.json` and `task-graph.json` +- `path-leases.json` +- `worker-packets/codex-packet-index.json` +- `validation/patch-bundle-validation.json` +- `integration/integration-plan.json` +- `integration/integration-receipt.json` +- `integration/rejected-patches.json` +- `release-candidate/release-candidate.json` +- `release-candidate/demo-evidence.md` or `release-candidate/release-notes.md` + +The generated HTML links only to relative files inside the run directory. Missing optional artifacts are shown as missing text, not clickable links, so link validation can fail closed on broken or escaping links. + +## Next Action Rules + +The operator next action is deterministic: + +- Missing validation summary: `Generate or repair fixture validation summary` +- Failed validation: `Inspect validation-report.md and failing stage` +- Rejected bundles: `Review rejected bundles before release candidate` +- Human-review conflicts: `Resolve conflicts in conflict-report.md` +- Failed dry-run integration: `Run rebase or dry-run repair for affected bundles` +- Missing release candidate: `Create release candidate evidence` +- Passed validation with release candidate: `Ready for operator demo/release review` diff --git a/docs/parallel-delivery/patch-swarm-leasing.md b/docs/parallel-delivery/patch-swarm-leasing.md new file mode 100644 index 0000000..6d42f37 --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-leasing.md @@ -0,0 +1,121 @@ +# Patch Swarm Path Leasing and Workset Compatibility + +## Overview + +Patch Swarm path leasing turns `split-plan.json` and `task-graph.json` into a durable `path-leases.json` contract before prompts, validation, or integration run. The lease helper is `scripts/parallel_delivery_leases.py`, exposed through `cento parallel-delivery patch-swarm leases` and `validate-leases`. + +This layer validates task ownership and planned patch metadata only. It does not dispatch workers, apply patches, or mutate Taskstream/Redmine/story state. + +## Why Every Task Needs a Lease + +Every patch task must declare explicit `owned_paths` before prompt emission. Tasks that cannot safely own paths are blocked or rejected with evidence. Shared files must be read-only unless one task owns them and later tasks depend on that output through a dependency gate. + +## Read Many, Write Few + +Many tasks may share `read_only_paths`. Write ownership is exclusive: active leases may not own the same path, a parent/child path pair, or a directory/file pair. Different files in the same directory are allowed when neither task owns the parent directory. + +## Lease Artifact Schema + +`path-leases.json` includes: + +- `schema_version`, `artifact_type`, `run_id`, `created_at`, `updated_at` +- `provenance`, `lease_policy`, `leases`, `conflicts` +- `dependency_gates`, `parallel_groups`, `workset_manifest` +- `dirty_targets`, `warnings`, `evidence_pointers` + +Each lease includes `lease_id`, `task_id`, `state`, `lane`, `risk_tier`, `owned_paths`, `read_only_paths`, `guarded_paths`, `protected_paths`, `dirty_owned_paths`, allowed and blocked operations, dependencies, dependency gate, parallel group, manual-review flags, timestamp, and evidence pointers. + +## Stable Lease IDs + +Lease IDs are deterministic: + +```text +lease--<12_hex_sha256> +``` + +The hash uses the run id, task id, normalized owned paths, and normalized read-only paths. Dirty status changes warnings and risk metadata, not the lease ID. + +## Protected Paths + +Always rejected: + +- `.env`, `.env.*`, `.env.mcp` +- `.git/**` +- `*.pem`, `*.key` +- paths containing `secret`, `token`, or `credential` +- absolute paths, `..`, home-relative paths, and repo-root cleanup paths + +## Guarded Paths and Lockfiles + +Guarded paths such as `data/tools.json`, `data/cento-cli.json`, `Makefile`, config files, and lockfiles are allowed only when explicitly owned by one task. They force high risk, manual review, and minimal hunks. Lockfiles also require the task contract to mention lockfile, package, or dependency validation. + +## Dirty Target Handling + +The lease tool parses `git status --porcelain=v1` path names without reading untracked file contents. Dirty owned paths become high-risk, require manual review, require minimal hunks, and add a warning to preserve unrelated hunks and forbid reset, checkout, clean, or stash. + +## Overlap Detection + +The validator rejects exact owned-path overlap and parent/child ownership overlap. Shared read-only paths are allowed. Owned path plus another task's read-only path is allowed, with dependency gates used when generated output must be consumed later. + +## Dependency Gates + +`depends_on` edges from `task-graph.json` become dependency gates. Guarded paths, dirty targets, and manual review also create gates so unsafe parallelism is surfaced explicitly instead of hidden in grouping. + +## Parallel Groups + +Parallel groups include only tasks that can run together safely. Dependent tasks are separated. Manual-review or blocked tasks are placed in non-automated groups. Shared read-only context does not block parallel grouping. + +## Patch Operation Validation + +`planned-operations.json` can validate future patch bundle claims without applying patches. It rejects unowned changes, unsafe deletes, unowned renames, binary patches, broad cleanup paths, lockfile changes outside explicit contract, and attempts to modify read-only-only paths. + +## Workset Compatibility + +Patch Swarm emits `path-leases.json` as the canonical contract. When a safe automatable subset exists, it also emits a Workset v1-compatible `workset-manifest.json` for `cento workset check WORKSET --allow-creates --json`. Guarded/manual-review tasks stay in the richer Patch Swarm lease artifact. `workset-compatibility.json` records the discovered Workset format and any command-shape gaps. + +## CLI Examples + +```bash +cento parallel-delivery patch-swarm leases \ + --run-dir workspace/runs/parallel-delivery/lease-fixture \ + --run-id lease-fixture \ + --fixture \ + --json +``` + +```bash +cento parallel-delivery patch-swarm validate-leases \ + --run-dir workspace/runs/parallel-delivery/lease-fixture \ + --json +``` + +```bash +python3 scripts/parallel_delivery_leases.py check-operations \ + --run-dir workspace/runs/parallel-delivery/lease-fixture \ + --operations workspace/runs/parallel-delivery/lease-fixture/planned-operations.json \ + --json +``` + +## Fixture Run + +The deterministic fixture lives under `workspace/runs/parallel-delivery/lease-fixture/`. It includes `request.md`, `split-plan.json`, `task-graph.json`, `path-leases.json`, `lease-conflicts.json`, reports, Workset compatibility evidence, planned operations, `start-here.md`, and conflict examples. + +## Validation Commands + +```bash +python3 scripts/parallel_delivery_leases.py print-policy --json +python3 scripts/parallel_delivery_leases.py write-fixture --run-dir workspace/runs/parallel-delivery/lease-fixture --run-id lease-fixture --fixed-timestamp 2026-01-01T00:00:00Z --json +python3 scripts/parallel_delivery_leases.py validate --run-dir workspace/runs/parallel-delivery/lease-fixture --json +pytest -q tests/test_parallel_delivery_path_leases.py +``` + +## Unsafe Rejection Rules + +- Do not include secrets. +- Do not copy `.env.mcp`. +- Do not store OpenAI keys or local secret values. +- Do not store absolute secret paths. +- Do not directly mutate Taskstream/Redmine/story state. +- Do not claim validation passed without validation evidence. +- Do not write generated run artifacts outside `workspace/runs/`. +- Do not apply patches in the lease validation slice. diff --git a/docs/parallel-delivery/patch-swarm-planner.md b/docs/parallel-delivery/patch-swarm-planner.md new file mode 100644 index 0000000..0a4ddec --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-planner.md @@ -0,0 +1,129 @@ +# Patch Swarm Request Splitter and 100-Task Planner + +## Overview + +The Patch Swarm planner turns one high-level request into durable planning artifacts: `split-plan.json`, `task-graph.json`, and per-task contract drafts under `task-contracts/`. It is a planning and validation surface only. It does not dispatch workers, apply patches, call live Pro by default, or mutate Taskstream/Redmine/story state. + +## Planner Modes + +- `fixture` writes deterministic tasks for tests and demos. +- `no-model` uses request text and safe Cento surface hints to produce a conservative local plan. +- `proreq` writes ChatGPT Pro planning manifest and prompt artifacts without a live Pro call unless explicitly enabled in a future safe backend. +- `manual-import` validates and normalizes a Pro-generated split plan into Cento artifacts. + +## Candidate Target vs Candidate Count + +`--candidate-target` is the requested upper bound. In fixture mode it can produce exactly 5, 20, or 100 tasks for validation. In no-model mode it is a cap, not a promise. + +## Why 100 Tasks Is a Cap, Not a Goal + +Patch Swarm maximizes safe parallelism, not raw task count. Small requests should stay small. Broad requests can approach 100 only when task boundaries, path ownership, dependencies, and validation evidence remain clear. + +## Coarse Product Lanes + +The planner decomposes into coarse product lanes before smaller tasks: coordination, builder work, validation, docs/evidence, integration sequencing, and human handoff. + +## Task Lanes + +- `builder`: bounded source changes. +- `validator`: tests, fixtures, validation harnesses, and checks. +- `docs-evidence`: docs, runbooks, evidence summaries, and operator packets. +- `coordinator`: metadata, manifests, CLI routing, or cross-surface coordination. +- `integrator`: integration sequencing plans, not arbitrary patch application. +- `human-handoff`: subjective, device-bound, credential-bound, or unsafe-to-automate work. + +## Risk Tiers + +- `low`: docs, fixtures, read-only validation, isolated helpers. +- `medium`: bounded code changes with clear tests and ownership. +- `high`: CLI/registry/integration logic or cross-cutting behavior. +- `human`: subjective, credential-bound, production-operation, or device-only decisions. + +## Worker Profile Suggestions + +Profiles are suggestions only: `python-builder`, `cli-builder`, `schema-validator`, `test-writer`, `docs-evidence-writer`, `safe-integrator`, `factory-planner`, `workset-lease-planner`, and `human-operator`. + +## Path Ownership Rules + +Owned paths must be relative, slash-separated, deduplicated, and non-overlapping. Absolute paths, `..`, `.env.mcp`, and secret-like paths are rejected. When safe ownership cannot be inferred, the planner should keep owned paths empty and raise risk or human handoff instead of guessing. + +## Human Handoff Rules + +Requests involving visual polish, device-only checks, production credentials, real customers, manual approval, secrets, tokens, or `.env` values must create or mark a human-handoff task. + +## Split Plan Schema + +`split-plan.json` includes common schema metadata, request metadata, `candidate_target`, `candidate_count`, `max_parallel_agents`, `planner_mode`, planning policy, lane names, and task records. Every task includes ID, title, story, lane, risk tier, human handoff flag, worker profile, owned/read-only paths, dependencies, acceptance contract, validation commands, expected artifacts, integration notes, rejection triggers, and evidence pointers. + +## Task Graph Schema + +`task-graph.json` records every task as a node, `depends_on` / `blocks` / `shares_context` / `conflicts_with` edges, a topological order, and parallel groups bounded by `max_parallel_agents`. `depends_on` edges must be acyclic. + +## ProReq Planning Flow + +ProReq mode writes: + +```text +proreq/planning-manifest.json +proreq/chatgpt-pro-planner-prompt.md +proreq/manual-import-instructions.md +``` + +The prompt instructs ChatGPT Pro to produce a plan matching the schema, avoid overlapping paths, use coarse lanes first, mark human handoff tasks, and cap candidates at 100. + +## Manual Import Flow + +Manual import accepts a JSON split plan with `--import-plan`. It rejects invalid JSON, more than 100 candidates, duplicate IDs, unknown lanes, unknown dependencies, overlapping paths, unsafe paths, and missing acceptance or validation contracts for automated tasks. + +## CLI Examples + +```bash +cento parallel-delivery patch-swarm split \ + --request-file REQUEST.md \ + --candidate-target 20 \ + --max-parallel-agents 5 \ + --mode no-model \ + --run-dir workspace/runs/parallel-delivery/planner-run \ + --json +``` + +```bash +cento parallel-delivery patch-swarm split \ + --candidate-target 100 \ + --max-parallel-agents 5 \ + --fixture \ + --run-id planner-fixture \ + --run-dir workspace/runs/parallel-delivery/planner-fixture \ + --json +``` + +```bash +cento parallel-delivery patch-swarm split \ + --mode manual-import \ + --import-plan pro-plan.json \ + --run-dir workspace/runs/parallel-delivery/imported-plan \ + --json +``` + +## Validation Commands + +```bash +python3 -m json.tool data/tools.json >/dev/null +python3 -m json.tool data/cento-cli.json >/dev/null +cento parallel-delivery patch-swarm split --help +pytest -q tests/test_parallel_delivery_planner.py +``` + +## Fixture Run + +The deterministic fixture run lives at `workspace/runs/parallel-delivery/planner-fixture/` and includes `request.md`, `split-plan.json`, `task-graph.json`, `task-contracts/`, `proreq/`, `planner-report.md`, and `start-here.md`. + +## Unsafe Planning Rules + +- Do not include secrets. +- Do not copy `.env.mcp`. +- Do not store OpenAI keys or local secret values. +- Do not store absolute secret paths. +- Do not directly mutate Taskstream/Redmine/story state. +- Do not claim validation passed without validation evidence. +- Do not write generated run artifacts outside `workspace/runs/` unless the operator explicitly supplies a run directory for validation. diff --git a/docs/parallel-delivery/patch-swarm-proreq-prompts.md b/docs/parallel-delivery/patch-swarm-proreq-prompts.md new file mode 100644 index 0000000..c70b3f2 --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-proreq-prompts.md @@ -0,0 +1,127 @@ +# Patch Swarm ProReq and ChatGPT Pro Prompt Bundles + +## Overview + +Patch Swarm prompt bundles turn local run artifacts into copy/paste prompts for ChatGPT Pro. The generator writes Markdown and JSON only. It does not call ChatGPT Pro, OpenAI APIs, Codex, MCP, Taskstream, Redmine, or worker pools. + +## Operator Copy/Paste Flow + +Generate the bundle, open `prompt-index.md`, paste a prompt into ChatGPT Pro, then paste the returned Codex implementation packet into Codex. Start with `prompt-0001-master.md`, then use lane or task-cluster prompts as needed. + +## Inputs + +- `request.md` +- `split-plan.json` +- `task-graph.json` +- `path-leases.json` + +The generator can also create a deterministic fixture under `workspace/runs/parallel-delivery/proreq-fixture/`. + +## Outputs + +- `prompt-bundle.json` +- `prompt-index.json` +- `prompt-index.md` +- `prompts/prompt-0001-master.md` +- `prompts/prompt-*.md` +- `prompt-validation.json` +- `prompt-validation-report.md` +- `start-here.md` +- `temp-bridge.json` and `temp-current-prompt.md` when `--copy-to-temp` is used + +## Prompt Bundle Layout + +```text +workspace/runs/parallel-delivery/proreq-fixture/ + request.md + split-plan.json + task-graph.json + path-leases.json + prompt-bundle.json + prompt-index.json + prompt-index.md + prompts/ + prompt-validation.json + prompt-validation-report.md + start-here.md +``` + +## Prompt Index + +`prompt-index.json` records prompt ID, type, title, lane, task IDs, prompt path, SHA-256, owned paths, read-only paths, validation commands, evidence requirements, copy order, recommended model, and operator action. + +`prompt-index.md` is the human copy order guide. + +## Master Prompt + +The master prompt is always first: `prompts/prompt-0001-master.md`. It summarizes the full run and asks ChatGPT Pro to produce a paste-ready Codex implementation packet. + +## Lane Prompts + +Lane prompts focus on `builder`, `validator`, `docs-evidence`, `coordinator`, `integrator`, and `human-handoff` scopes. Task-cluster prompts group task IDs without inventing tasks. + +## Count and Lane Options + +`--count` controls prompt count, not task count. It accepts `1..20`; use `15` or `20` for operator bundles. + +`--lane all` includes all lanes. `--lane builder` emits a master prompt plus builder-scoped prompts and run-level review/evidence prompts scoped to the builder lane. + +## Temp Bridge + +`--copy-to-temp` writes a local mirror under: + +```text +workspace/runs/temp/chatgpt-pro// +``` + +It also writes a `temp-bridge.json` manifest with the generated `current.md` path. `cento temp run` no longer reads generated temp entries; it is a fixed pbcopy wrapper. To copy a generated prompt through that bridge, edit `COPY_FILE` in `scripts/cento_temp.sh` to the generated `current.md` path. The generator does not copy to the OS clipboard. + +## Safety Rules + +- Do not read or copy local secret files. +- Do not include local environment values, tokens, keys, credentials, or local secret values. +- Redact secret-like strings from request/context text. +- Do not include untracked file contents or broad repo dumps. +- Do not call live AI services or worker systems by default. +- Do not mutate Taskstream, Redmine, or story state through direct database writes. +- Do not reset, checkout, clean, stash, or overwrite unrelated work. + +## CLI Examples + +```bash +cento parallel-delivery patch-swarm prompts \ + --run-dir workspace/runs/parallel-delivery/proreq-fixture \ + --count 20 \ + --lane all \ + --chatgpt-pro \ + --copy-to-temp \ + --json +``` + +```bash +python3 scripts/parallel_delivery_prompts.py write-fixture \ + --run-dir workspace/runs/parallel-delivery/proreq-fixture \ + --run-id proreq-fixture \ + --count 20 \ + --fixed-timestamp 2026-01-01T00:00:00Z \ + --copy-to-temp \ + --json +``` + +## Fixture Run + +The deterministic fixture writes 20 source tasks and exactly the requested number of prompts. `--count 15` writes 15 prompts. `--count 20` writes 20 prompts and reserves `prompt-0020-evidence.md` as the final evidence handoff prompt. + +## Validation Commands + +```bash +python3 scripts/parallel_delivery_prompts.py print-policy --json +python3 scripts/parallel_delivery_prompts.py validate-bundle \ + --run-dir workspace/runs/parallel-delivery/proreq-fixture \ + --json +pytest -q tests/test_parallel_delivery_proreq_prompts.py +``` + +## Failure Handling + +If source artifacts are missing, the generator can create fixture inputs for local validation. If prompt validation fails, it writes exact errors in `prompt-validation.json` and `prompt-validation-report.md`. Temp bridge file-argument compatibility is documented as a bridge note rather than a prompt-bundle failure. diff --git a/docs/parallel-delivery/patch-swarm-taskstream.md b/docs/parallel-delivery/patch-swarm-taskstream.md new file mode 100644 index 0000000..f9005ef --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-taskstream.md @@ -0,0 +1,112 @@ +# Patch Swarm Taskstream Handoff + +Patch Swarm can emit Cento `agent-work` handoff manifests from a validated split plan without creating live Taskstream stories. This is the bridge between high-fanout Patch Swarm planning and the existing Taskstream operating model. + +The adapter is dry-run by default. It writes local work-package directories containing: + +- `story.json` +- `validation.json` +- `handoff.md` +- `agent-work-command.txt` + +Live creation is gated behind `cento parallel-delivery taskstream apply --apply` and uses the existing `cento agent-work create --manifest ...` command path. It does not write Taskstream, Redmine, story, board, or cluster state directly. + +## Commands + +Generate handoff manifests: + +```bash +cento parallel-delivery taskstream emit \ + --split-plan workspace/runs/parallel-delivery/taskstream-fixture/input/split-plan.json \ + --out workspace/runs/parallel-delivery/taskstream-fixture \ + --transport manifest-only \ + --run-preflight +``` + +Run preflight over generated packages: + +```bash +cento parallel-delivery taskstream preflight \ + --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages \ + --out workspace/runs/parallel-delivery/taskstream-fixture/preflight +``` + +Refuse live creation unless explicitly applied: + +```bash +cento parallel-delivery taskstream apply \ + --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages \ + --out workspace/runs/parallel-delivery/taskstream-fixture/live-refusal \ + --transport agent-work +``` + +The final command must fail because `--apply` is absent. A live create run must be explicit: + +```bash +cento parallel-delivery taskstream apply \ + --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages \ + --out workspace/runs/parallel-delivery/taskstream-fixture/apply \ + --transport agent-work \ + --apply +``` + +## Artifact Contract + +`taskstream emit` accepts existing Patch Swarm `split-plan.json` artifacts and the fallback fixture schema `cento.parallel_delivery.split_plan.v1`. + +Each generated `story.json` uses the existing agent-work story format: + +- `schema_version: "1.0"` +- `issue.id: 0` for create-time draft manifests +- `issue.title` and `issue.package` +- `lane.owner`, `lane.role`, `lane.node`, and `lane.agent` +- `paths.run_dir` +- `scope.acceptance` +- `expected_outputs` +- `validation.manifest`, `validation.mode`, `validation.commands` + +The adapter also adds Patch Swarm metadata such as `source`, `run_id`, `request_id`, `task_id`, `owned_paths`, `touched_path_candidates`, `acceptance_contract`, and `evidence_links`. + +Each generated `validation.json` uses the existing `cento.validation-manifest.v1` checks format so `cento agent-work preflight story.json --validation-manifest validation.json` can validate it. Patch Swarm metadata is included as supplemental fields. + +## Routing + +Routing is deterministic: + +- Tasks with implementation scope, acceptance criteria, and validation commands route to `agent-work`. +- Evidence-only, planning-only, blocked, or explicitly `manifest-only` tasks remain local manifests. + +`agent-work-command.txt` is only a command preview in dry-run mode. The command uses the approved `cento agent-work create --manifest ...` surface. + +## Guards + +The adapter rejects unsafe manifest inputs: + +- absolute paths +- traversal paths +- Windows drive paths +- NUL bytes +- `.env`, `.env.*`, `.env.mcp` +- local secret-looking paths +- secret-looking inline values + +Generated evidence stays under `workspace/runs/parallel-delivery/taskstream-fixture/` for the fixture path. Tests never create live Taskstream issues. + +## Fixture + +Run: + +```bash +make test-taskstream-handoff +make taskstream-fixture +``` + +The fixture writes: + +- `workspace/runs/parallel-delivery/taskstream-fixture/input/split-plan.json` +- `workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/story.json` +- `workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/validation.json` +- `workspace/runs/parallel-delivery/taskstream-fixture/work-packages/*/handoff.md` +- `workspace/runs/parallel-delivery/taskstream-fixture/taskstream-handoff-report.json` +- `workspace/runs/parallel-delivery/taskstream-fixture/taskstream-handoff-report.md` +- `workspace/runs/parallel-delivery/taskstream-fixture/validation-summary.txt` diff --git a/docs/parallel-delivery/patch-swarm-validation-e2e.md b/docs/parallel-delivery/patch-swarm-validation-e2e.md new file mode 100644 index 0000000..914f70c --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-validation-e2e.md @@ -0,0 +1,192 @@ +# Patch Swarm Deterministic Validation and Fixture E2E + +## Overview + +Patch Swarm deterministic validation E2E proves the local fixture flow from request intake through release-candidate evidence. It is a dry-run evidence path: fixture workers write local artifacts, patch bundles are validated against leases, integration records what would be applied, and no repository source files are patched. + +The implementation is local-only. It does not call ChatGPT Pro, OpenAI APIs, Codex, MCP, Taskstream, Redmine, or live worker pools. + +## Acceptance Command + +```bash +cento parallel-delivery patch-swarm e2e \ + --candidate-target 100 \ + --max-parallel-agents 5 \ + --fixture \ + --json +``` + +For deterministic validation evidence: + +```bash +cento parallel-delivery patch-swarm e2e \ + --candidate-target 100 \ + --max-parallel-agents 5 \ + --fixture \ + --run-id fixture-100-agents \ + --run-root workspace/runs/parallel-delivery/e2e-fixture \ + --fixed-timestamp 2026-01-01T00:00:00Z \ + --json +``` + +## Fixture Flow + +The fixture flow is: + +```text +request + -> split + -> leases + -> worker packets + -> fixture patch bundles + -> patch validation + -> malformed artifact rejection + -> integration plan + -> dry-run integration receipt + -> validation summary + -> release candidate evidence +``` + +## 100 Candidate Tasks with 5 Simulated Workers + +`--candidate-target 100 --max-parallel-agents 5` creates 100 deterministic candidate tasks and 20 simulated worker batches. Simulated workers are local artifact writers only; they do not launch Codex or agents. + +Every task is assigned to one batch, and no batch exceeds `max_parallel_agents`. + +## Validation Engine + +The validation engine writes and checks: + +- `split-plan.json` +- `task-graph.json` +- `path-leases.json` +- `worker-packets/codex-packet-index.json` +- `patch-bundles/*.patch-bundle.json` +- `integration/integration-plan.json` +- `integration/integration-receipt.json` +- `release-candidate/release-candidate.json` + +Use the direct helper to inspect policy or validate an existing run: + +```bash +python3 scripts/parallel_delivery_validation_e2e.py print-policy --json +python3 scripts/parallel_delivery_validation_e2e.py validate-run --run-dir workspace/runs/parallel-delivery/e2e-fixture/fixture-100-agents --json +``` + +## Positive Checks + +Positive checks require: + +- every task has one lease +- owned lease paths do not overlap +- every task has one worker packet +- every valid fixture patch bundle changes only owned paths +- integration queue contains accepted bundles only +- dry-run receipt includes accepted bundles only +- release-candidate evidence exists + +## Negative Checks + +The fixture includes negative checks that pass only when unsafe input is rejected. + +## Unsafe Bundle Rejection + +The fixture writes `patch-bundles/unsafe-out-of-lease.patch-bundle.json`. It intentionally changes `README.md` without owning that path. Validation rejects it with a changed-path-outside-owned-lease reason, and the integration plan excludes it. + +## Malformed Artifact Rejection + +The fixture writes `validation/malformed/missing-run-id.json`. The malformed artifact is rejected because it omits `run_id`; `validation/malformed-artifact-validation.json` records the negative check. + +## Dry-Run Integration + +Dry-run integration writes: + +- `integration/integration-plan.json` +- `integration/integration-receipt.json` +- `integration/dry-run-apply-log.jsonl` + +No diffs are applied. The receipt state is `dry_run_completed`. + +## Release Candidate Evidence + +Release-candidate evidence is fixture-only: + +- `release-candidate/release-candidate.json` +- `release-candidate/release-notes.md` + +It records `rc_fixture_validated` and does not claim a production release. + +## Run Directory Layout + +Runs are written under: + +```text +workspace/runs/parallel-delivery/e2e-fixture// +``` + +Required files include: + +```text +request.md +run.json +context-pack.json +split-plan.json +task-graph.json +path-leases.json +worker-packets/codex-packet-bundle.json +worker-packets/codex-packet-index.json +fixture-workers/simulated-worker-ledger.jsonl +patch-bundles/ +validation/artifact-validation.json +validation/lease-validation.json +validation/packet-validation.json +validation/patch-bundle-validation.json +validation/malformed-artifact-validation.json +integration/integration-plan.json +integration/integration-receipt.json +integration/rejected-patches.json +integration/dry-run-apply-log.jsonl +release-candidate/release-candidate.json +release-candidate/release-notes.md +validation-summary.json +validation-report.md +command-output.log +start-here.md +``` + +## JSON Output + +`--json` emits one deterministic JSON object on stdout. Logs and evidence are written to files. Important fields include `ok`, `run_id`, `run_dir`, `candidate_target`, `candidate_count`, `max_parallel_agents`, `simulated_worker_batches`, `accepted_patch_bundles`, `rejected_patch_bundles`, `overall`, `validation_summary`, and `validation_report`. + +## CLI Examples + +```bash +cento parallel-delivery patch-swarm e2e --candidate-target 5 --max-parallel-agents 5 --fixture --json +cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json +python3 scripts/parallel_delivery_validation_e2e.py validate-run --run-dir workspace/runs/parallel-delivery/e2e-fixture/fixture-100-agents --json +``` + +## Troubleshooting + +If `overall` is `failed`, inspect: + +- `validation-summary.json` +- `validation-report.md` +- `validation/patch-bundle-validation.json` +- `integration/rejected-patches.json` + +Common causes are overlapping owned paths, missing worker packets, malformed patch bundle JSON, changed paths outside the lease, or missing evidence files. + +## Unsafe Rules + +The fixture rejects protected or unsafe behavior: + +- no secret-like paths +- no `.env` or `.env.mcp` +- no paths outside owned leases +- no binary patches +- no unsafe deletes +- no unowned renames +- no direct Taskstream/Redmine/story database mutation +- no destructive git commands +- no live AI/API/agent dispatch diff --git a/docs/parallel-delivery/patch-swarm-worker-status.md b/docs/parallel-delivery/patch-swarm-worker-status.md new file mode 100644 index 0000000..74f59e8 --- /dev/null +++ b/docs/parallel-delivery/patch-swarm-worker-status.md @@ -0,0 +1,95 @@ +# Patch Swarm Worker Pool and Process Visibility + +## Overview + +Patch Swarm worker status is a local, dry-run-first layer for planning bounded worker dispatch and rendering operator-visible status. It consumes `split-plan.json`, `task-graph.json`, `path-leases.json`, and optional worker packet metadata, then writes worker pool, dispatch, queue, status, stale/risk, process visibility, and Console/UI artifacts. + +## 100 Candidate Tasks, Bounded Workers + +Patch Swarm may represent up to 100 candidate tasks, but it must not launch 100 workers blindly. `max_parallel_agents` controls batch size. The fixture proves 100 tasks with `max_parallel_agents=5`, producing 20 planned batches with no task appearing in more than one batch. + +## Dry-Run Dispatch + +Dry-run dispatch is the default. `cento parallel-delivery patch-swarm dispatch --dry-run --fixture --json` writes dispatch metadata and queue events without running `cento agent-pool-kick`, external Codex, tmux commands, or process/session mutation. Unsupported `--live` dispatch fails closed unless a later explicit backend owns it. + +## Worker Pool Plan + +`worker-pool-plan.json` records candidate count, max parallel agents, dispatch policy, task lease metadata, bounded batches, blocked/stale indicators, warnings, and evidence pointers. It is the operator review artifact for deciding whether a run can move from local planning to an explicit live dispatch path. + +## Queue Ledger + +`worker-queue-ledger.jsonl` is append-style JSONL. Events include `queue_created`, `task_queued`, `dispatch_planned`, `dispatch_skipped_dry_run`, `worker_active`, `worker_completed`, `worker_blocked`, `worker_stale`, `worker_failed`, and `status_snapshot`. + +## Worker Status JSON + +`worker-status.json` summarizes active, pending, completed, blocked, stale, and failed task counts. The fixture state is 5 active dry-run workers, 92 pending tasks, 1 completed task, 1 blocked task, 1 stale task, and 0 failed tasks. + +## Console/UI Status JSON + +`console-status.json` is a compact UI payload with run state, candidate count, max workers, status counts, risk level, stale indicators, risk indicators, artifact links, and the next operator action. Detailed ledgers stay in separate artifacts. + +## Stale and Risk Indicators + +Stale detection considers state, `updated_at`, `last_heartbeat_at`, `stale_after_seconds`, blocked reasons, risk tier, dependency gates, dirty/manual-review flags, missing leases, missing worker packets, and process visibility mismatches. The default fixture threshold is 3600 seconds. + +## Process Visibility Compatibility + +`process-visibility.json` bridges local Patch Swarm status to existing process surfaces without assuming process availability. Fixture process IDs are `null`, status is `dry_run_not_launched`, and platform process inspection is recorded as unavailable unless a safe backend explicitly provides it. + +## agent-pool-kick Integration + +`agent-pool-kick` already supports `--dry-run` and bounded `--max-launch` behavior. Patch Swarm records compatible metadata but does not call it by default. Live launch remains a separate explicit opt-in path. + +## agent-processes Integration + +`agent-processes` is read-only status visibility. Operators can use `cento agent-processes --once` outside the fixture to inspect current managed/manual sessions. Patch Swarm does not mutate or reset those sessions. + +## Cluster and Bridge Status + +`cento cluster status` and `cento bridge status` are read-only compatibility surfaces. Patch Swarm status records the commands and availability metadata but does not heal, start, stop, restart, or execute remote commands. + +## Platform Guards + +The worker-status helper uses standard-library platform checks and `shutil.which`. It does not assume tmux, Linux `ps`, macOS process fields, or OCI bridge availability. Missing process support is a warning, not a fixture failure. + +## CLI Examples + +```bash +cento parallel-delivery patch-swarm dispatch \ + --run-dir workspace/runs/parallel-delivery/worker-status-fixture \ + --run-id worker-status-fixture \ + --candidate-target 100 \ + --max-parallel-agents 5 \ + --dry-run \ + --fixture \ + --json + +cento parallel-delivery patch-swarm worker-status \ + --run-dir workspace/runs/parallel-delivery/worker-status-fixture \ + --json + +cento parallel-delivery status \ + --run worker-status-fixture \ + --run-root workspace/runs/parallel-delivery \ + --json +``` + +## Fixture Run + +The deterministic fixture lives under `workspace/runs/parallel-delivery/worker-status-fixture/` and writes `request.md`, `split-plan.json`, `task-graph.json`, `path-leases.json`, `worker-pool-plan.json`, `dry-run-dispatch.json`, `worker-queue-ledger.jsonl`, `worker-status.json`, `worker-status-report.md`, `stale-workers.json`, `process-visibility.json`, `console-status.json`, and `start-here.md`. + +## Validation Commands + +```bash +python3 scripts/parallel_delivery_worker_status.py validate-status \ + --run-dir workspace/runs/parallel-delivery/worker-status-fixture \ + --json + +pytest -q tests/test_parallel_delivery_worker_status.py +``` + +Validation checks parseability, batch bounds, duplicate task dispatch, status count consistency, stale/blocked fixture detection, dry-run launch refusal, process read-only flags, and Console/UI fields. + +## Unsafe Operations + +The worker-status layer must not launch external agents, mutate tmux/process state, kill/restart processes, write Taskstream/Redmine directly, inspect secrets, copy environment values, apply patches, or reset/clean/stash/checkout unrelated work. diff --git a/docs/parallel-delivery/release-candidate-safe-apply.md b/docs/parallel-delivery/release-candidate-safe-apply.md new file mode 100644 index 0000000..5a7d2b5 --- /dev/null +++ b/docs/parallel-delivery/release-candidate-safe-apply.md @@ -0,0 +1,106 @@ +# Parallel Delivery Safe Apply And Release Candidate + +## Overview + +`cento parallel-delivery release-candidate create` turns an accepted integration receipt into deterministic apply evidence. It verifies accepted patch bundle receipts, refuses rejected or non-integratable bundles, checks patch SHA-256 values, performs mechanical dry-run checks, and writes apply receipts, rollback metadata, and release-candidate artifacts. + +The command is local-only. It does not dispatch workers, call AI APIs, mutate Taskstream or Redmine state, merge branches, push commits, or apply patches to the operator worktree. + +## CLI + +```bash +cento parallel-delivery release-candidate create \ + --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json \ + --out workspace/runs/parallel-delivery/release-candidate-fixture/dry-run \ + --mode dry-run \ + --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo \ + --base-commit "$(git rev-parse HEAD)" \ + --json +``` + +```bash +cento parallel-delivery release-candidate create \ + --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json \ + --out workspace/runs/parallel-delivery/release-candidate-fixture/apply \ + --mode apply \ + --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo \ + --target-worktree workspace/runs/parallel-delivery/release-candidate-fixture/integration-worktree \ + --base-commit "$(git rev-parse HEAD)" \ + --final-validation-cmd "python -m pytest -q tests" \ + --json +``` + +Dry-run is the default-safe mode. Apply mode requires an explicit isolated target worktree under `workspace/runs/`, `workspace/factory-integration-worktrees/`, or `/tmp`. + +## Inputs + +The command reads `cento.parallel_delivery.integration_receipt.v1`: + +- `status` must be `accepted`. +- `accepted_bundle_receipts` lists local bundle receipt paths. +- `rejected_bundle_receipts` is retained as evidence but is never applied. +- `apply_order` controls deterministic bundle order. +- `final_validation_commands` run after successful apply before a ready release candidate can be written. + +Each applied bundle receipt must be accepted, `integratable=true`, contain a patch path, and contain a matching `patch_sha256`. + +## Outputs + +The output directory contains: + +- `apply-report.json` +- `apply-report.md` +- `apply-receipts/step-NNN-.json` +- `logs/*.stdout` +- `logs/*.stderr` +- `rollback-metadata.json` + +Successful apply mode also writes: + +- `release-candidate.json` +- `release-notes.md` +- `integrated.diff` + +Rejected receipts write `refusal.json` and exit non-zero. + +## Safety Rules + +The safe apply layer refuses unsafe command snippets such as `git reset`, `git checkout`, `git clean`, `git stash`, `.env.mcp`, and direct Taskstream or Redmine database commands. Rollback is metadata-only: the recorded strategy is isolated worktree abandonment or dry-run no changes. + +Dry-run runs `git apply --check --whitespace=error-all` for each accepted bundle and applies zero patches. Apply mode first runs the same mechanical check, then applies bundles sequentially with `git apply` in the isolated target, validates after each bundle, and stops on the first patch or validation failure. + +## Schemas + +This layer writes: + +- `cento.parallel_delivery.apply_step_receipt.v1` +- `cento.parallel_delivery.apply_report.v1` +- `cento.parallel_delivery.rollback_metadata.v1` +- `cento.parallel_delivery.release_candidate.v1` + +It also supports the minimal local input schemas: + +- `cento.parallel_delivery.integration_receipt.v1` +- `cento.parallel_delivery.bundle_receipt.v1` + +## Fixture + +Generate fixture inputs: + +```bash +python3 scripts/parallel_delivery/release_candidate_fixture.py \ + --out workspace/runs/parallel-delivery/release-candidate-fixture \ + --base-commit "$(git rev-parse HEAD)" +``` + +The fixture creates a tiny isolated target repo, two accepted bundle receipts, one rejected bundle receipt, accepted and rejected integration receipts, and patch files under `workspace/runs/parallel-delivery/release-candidate-fixture/input/`. + +## Validation + +```bash +python3 -m pytest -q \ + tests/test_parallel_delivery_safe_apply.py \ + tests/test_parallel_delivery_release_candidate.py +``` + +The tests cover accepted and rejected integration receipts, rejected and non-integratable bundle receipts, patch hash mismatch refusal, dry-run no-mutation behavior, sequential apply, first-failure stopping, rollback metadata, release-candidate creation, and CLI JSON output. diff --git a/docs/parallel-integration-train.md b/docs/parallel-integration-train.md new file mode 100644 index 0000000..88a3844 --- /dev/null +++ b/docs/parallel-integration-train.md @@ -0,0 +1,85 @@ +# Parallel Integration Train + +`cento parallel-delivery train` is the dry-run bridge between aggressive parallel Workset planning and safe sequential integration. + +The train exists so Cento can prepare for wave-10 delivery without letting workers or integrations mutate the repository in the first implementation. It copies a Workset into a run bundle, runs the existing Workset checker, builds a dependency-aware integration queue, can either simulate worker readiness or invoke the existing parallel Workset executor, and records dry-run integration receipts. + +## Commands + +```bash +cento parallel-delivery train plan --workset tests/fixtures/cento_workset/workset.valid.json --max-parallel 10 --json +cento parallel-delivery train run RUN_ID --simulate --json +cento parallel-delivery train run RUN_ID --workset-execute --runtime fixture --validation smoke --allow-dirty-owned --json +cento parallel-delivery train promote RUN_ID --dry-run --json +cento parallel-delivery train e2e --workset tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --allow-dirty-owned --dry-run --json +cento parallel-delivery train integrate RUN_ID --dry-run --json +cento parallel-delivery train status RUN_ID --json +cento parallel-delivery train validate RUN_ID --json +``` + +The `run` command requires either `--simulate` or `--workset-execute`. `--workset-execute` routes through `cento workset execute` with the copied Workset and records the Workset command, result, and receipt into the train run. The train wrapper never passes `--apply`; repository mutation stays outside this MVP. `--runtime api-openai` is available only when explicitly requested and requires both `--budget-usd` and `--max-budget-usd`. + +The `promote` command is the missing e2e bridge that previously made the flow feel circular. It reads a completed train Workset receipt, creates a Factory run, converts accepted Workset patch bundles into Factory patch collection, builds the Factory apply plan, and writes a promotion decision. `--dry-run` is the default behavior. `--apply` is explicit and applies only inside a Factory integration worktree branch before rendering a release candidate. + +The `e2e` command runs plan, Workset execution, train validation, and promotion in one call. + +The `integrate` command is still for the simulation path and requires `--dry-run`. + +## Artifacts + +Train runs write under: + +```text +workspace/runs/parallel-delivery/train// +``` + +The stable artifact set is: + +- `train_manifest.json` +- `workset.json` +- `workset_check.json` +- `integration_queue.json` +- `train_receipt.json` +- `workset_execute_command.json` +- `workset_execute_result.json` +- `promotion_manifest.json` +- `promotion_decision.json` +- `promotion_decision.md` +- `factory_handoff.json` +- `events.ndjson` +- `decision_report.md` +- `workers//worker_receipt.json` +- `integration//integration_receipt.json` +- `validation_summary.json` + +## Behavior + +- Worksets are checked through `cento workset check`; the train does not duplicate the lower-level Workset contract. +- Shards keep `task_id`, `worker_id`, `write_paths`, `depends_on`, blockers, and integration order. +- Overlapping, glob, absolute, missing, or dependency-broken write paths are blocked before worker simulation. +- Dependency order is stable and sequential for integration. +- Simulated workers move ready shards to `ready_for_integration`. +- Dry-run integration moves ready shards to `integration_planned`. +- Workset execution runs `cento workset execute WORKSET --integrate sequential --json` and stores the Workset receipt path on each queue item. +- Workset task statuses of `accepted`, `applied`, or `completed` become train queue status `workset_integrated`. +- Promotion hands accepted Workset patch bundles to Factory/Safe Integrator instead of inventing a second integration authority. +- Promotion dry-run stops at Factory patch collection plus apply-plan generation. +- Promotion apply mode uses Factory integration worktree behavior and does not merge to main. +- Train planning and Workset execution keep `apply` false; promotion apply is a separate explicit integration-worktree mode. + +## Current State + +Already implemented: + +- Train planning and dependency-aware queue generation. +- Simulated train worker readiness. +- Real Workset execution via `train run --workset-execute`. +- Train validation for Workset execution receipts. +- Factory/Safe Integrator promotion via `train promote`. +- One-command fixture e2e via `train e2e`. + +Still intentionally out of scope: + +- Automatic merge to main. +- Uncapped live API fanout. +- Bypassing Factory release-candidate gates. diff --git a/docs/patch-swarm-implementation-map.md b/docs/patch-swarm-implementation-map.md new file mode 100644 index 0000000..046e3b6 --- /dev/null +++ b/docs/patch-swarm-implementation-map.md @@ -0,0 +1,243 @@ +# Patch Swarm Implementation Map + +This map decomposes the Patch Swarm / Parallel Software Delivery product contract into future implementation slices. Each milestone should preserve the existing Cento surfaces: `cento parallel-delivery`, `cento factory`, `cento workset`, `cento build`, `cento agent-work`, `cento mcp`, ProReq, Taskstream visibility, and Factory/Safe Integrator. + +## Milestone 0: Canonical Spec and Docs + +Purpose: Create the source-of-truth product architecture, lifecycle, implementation map, and validation matrix. + +Owned artifacts: `docs/patch-swarm.md`, `docs/patch-swarm-lifecycle.md`, `docs/patch-swarm-implementation-map.md`, `docs/patch-swarm-validation-matrix.md`, `workspace/runs/patch-swarm-call-1-product-architecture/`. + +Likely commands/subcommands: `cento docs parallel-delivery`, `cento docs factory`, `cento docs workset`, `cento docs build`. + +Inputs: Existing docs, registered tool metadata, operator mission. + +Outputs: Canonical docs and evidence summary. + +Validation commands: grep required headings/states/contracts; run docs smoke commands. + +Evidence files: `discovery.log`, `docs-created-or-updated.txt`, `spec-summary.md`, `validation.log`. + +Failure handling: Record failed validation with exact missing section or command output. + +Acceptance criteria: Docs exist, are linked from the canonical spec, and define product, CLI contract, artifacts, states, safety, e2e done, and future slices. + +## Milestone 1: Run Directory and Artifact Schema + +Purpose: Implement the logical `workspace/runs//` schema while staying compatible with current `workspace/runs/parallel-delivery/patch-swarm//` artifacts. + +Owned artifacts: `run.json`, `evidence/commands.log`, `evidence/artifacts.json`, schema validation fixtures. + +Likely commands/subcommands: `cento parallel-delivery init --request-file REQUEST.md [--run-id RUN_ID]`, current `cento parallel-delivery patch-swarm plan --run-id RUN_ID`. + +Inputs: Request file, optional run ID, current registry path conventions. + +Outputs: Run directory, initialized `run.json`, artifact index, command log. + +Validation commands: `test -f RUN/run.json`, JSON schema check, path-root guard, resume/idempotency check. + +Evidence files: `run.json`, `evidence/commands.log`, `evidence/artifacts.json`. + +Failure handling: Reject duplicate run IDs unless resume is explicit; fail closed on paths outside `workspace/runs/`. + +Acceptance criteria: A request creates one durable run root and no unrelated repository files are mutated. + +## Milestone 2: Request Intake / ProReq Packet + +Purpose: Convert the operator request into a strict ProReq/product request packet that drives planning and validation. + +Owned artifacts: `request/request.md`, `request/proreq.json`, intake receipt, normalized request title. + +Likely commands/subcommands: `cento parallel-delivery init --request-file REQUEST.md`, ProReq-compatible helper calls, current Hard ProReq/ProReq-light surfaces where appropriate. + +Inputs: Request markdown, optional product metadata, risk/budget limits. + +Outputs: `request/proreq.json` with goals, acceptance checks, constraints, read context, owned path candidates, validation expectations, budget, and non-goals. + +Validation commands: JSON schema check; required fields check; secret/path safety scan. + +Evidence files: Intake receipt, request hash, `evidence/commands.log`. + +Failure handling: Reject missing acceptance criteria, local secret values, direct DB mutation requests, or unbounded worker instructions. + +Acceptance criteria: Every downstream task can cite the ProReq acceptance contract and risk limits. + +## Milestone 3: Factory Task Splitter + +Purpose: Split the ProReq packet into 2-100 bounded candidate patch tasks without inventing a second planner. + +Owned artifacts: `plan/decomposition.json`, `plan/task_graph.json`, `plan/risks.json`. + +Likely commands/subcommands: `cento parallel-delivery plan --run RUN_ID --max-tasks 100`, `cento factory plan`, existing Patch Swarm decomposition helpers. + +Inputs: `request/proreq.json`, existing repo context, max task count. + +Outputs: Candidate tasks with `task_id`, title, owned path candidates, read-only paths, dependencies, acceptance contract, validation commands, and risks. + +Validation commands: Task count `<= 100`; no task lacks acceptance contract; dependency graph is acyclic; shared-file pressure is surfaced. + +Evidence files: Planner receipt, task graph, risks summary. + +Failure handling: Reject over-broad tasks, overlapping candidate ownership not resolvable by Workset, and duplicate workflow plans. + +Acceptance criteria: The planner emits a bounded graph that can be leased by Workset. + +## Milestone 4: Workset Path Leasing + +Purpose: Turn task-owned path candidates into exclusive leases and read-only classifications. + +Owned artifacts: `leases/path_leases.json`, lease failure reports, Workset-compatible manifest. + +Likely commands/subcommands: `cento workset check WORKSET`, `cento parallel-delivery plan --run RUN_ID --max-tasks N` with lease emission, future `cento parallel-delivery emit-prompts`. + +Inputs: Task graph, repo path inventory, protected paths, generated artifact targets. + +Outputs: Non-overlapping leases, blocked paths, explicit serialized integrator tasks for shared edits. + +Validation commands: `cento workset check`; overlap/glob/absolute path rejection tests; protected path fixtures. + +Evidence files: `leases/path_leases.json`, Workset check receipt, lease conflict report. + +Failure handling: Reject overlapping writes; move shared edits into serialized integrator tasks only when deterministic and recorded. + +Acceptance criteria: No task can dispatch without an exclusive lease or read-only classification. + +## Milestone 5: Worker Prompt Packet Emission + +Purpose: Emit worker-ready prompt packets that preserve task boundaries and evidence requirements. + +Owned artifacts: `prompts/task-0001.md`, prompt index, prompt emission receipt. + +Likely commands/subcommands: `cento parallel-delivery emit-prompts --run RUN_ID --out workspace/runs/RUN_ID/prompts`, `cento build prompt MANIFEST`. + +Inputs: ProReq packet, task graph, leases, validation commands, unsafe rejection rules. + +Outputs: Prompt markdown per task, prompt manifest, task states moved to `prompt_emitted`. + +Validation commands: grep prompt for task ID, owned paths, acceptance contract, validation commands, patch bundle schema, and dirty-work preservation rule. + +Evidence files: Prompt emission receipt, prompt index, state transition log. + +Failure handling: Refuse prompt emission for tasks without acceptance contract, validation command, or path lease. + +Acceptance criteria: Each prompt is self-contained enough for a worker and cannot authorize writes outside its lease. + +## Milestone 6: Patch Bundle Collection + +Purpose: Collect worker outputs into normalized patch bundles and preserve raw evidence. + +Owned artifacts: `workers//patch.bundle.json`, `workers//patch.diff`, `workers//state.json`, worker evidence directory. + +Likely commands/subcommands: `cento parallel-delivery collect --run RUN_ID --patch-dir workspace/runs/RUN_ID/inbox`, current `cento parallel-delivery patch-swarm execute RUN_ID --fixture|--live`, `cento build bundle synthesize`. + +Inputs: Inbox path, worker artifacts, candidate patches, transcripts, evidence files. + +Outputs: Normalized bundle records, per-task state changes to `patch_submitted`, collection receipt. + +Validation commands: JSON schema check; diff path exists; base ref present; claimed/changed paths present. + +Evidence files: Collection receipt, worker evidence, bundle index. + +Failure handling: Reject malformed bundles, stale base refs, missing diffs, and changed paths outside claimed paths before validation. + +Acceptance criteria: Every submitted patch has a structured bundle and traceable evidence. + +## Milestone 7: Deterministic Task Validation + +Purpose: Validate each patch before it can enter the integration queue. + +Owned artifacts: `validation/.validation.json`, `validation/matrix.json`, rejection receipts. + +Likely commands/subcommands: `cento parallel-delivery validate --run RUN_ID`, `cento build artifact check`, `cento build integrate --dry-run`, `cento factory validate-fanout RUN_ID --max-parallel N --json`. + +Inputs: Patch bundles, leases, task acceptance contracts, validation commands. + +Outputs: Per-task pass/fail receipts, validation matrix, rejection reasons. + +Validation commands: Schema, path lease, dirty-work, secret scan, direct DB mutation scan, required command evidence, focused tests. + +Evidence files: Validation receipts, command logs, rejected patch evidence. + +Failure handling: Reject unsafe tasks but continue the run if at least one safe patch remains. + +Acceptance criteria: Only `validation_passed` tasks can become `queued_for_integration`. + +## Milestone 8: Safe Integrator + +Purpose: Integrate validated patches in recorded sequential or dependency order through Factory/Safe Integrator. + +Owned artifacts: `integration/queue.json`, `integration/integrated-patches.json`, `integration/rejected-patches.json`, `integration/conflicts.json`. + +Likely commands/subcommands: `cento parallel-delivery integrate --run RUN_ID --strategy sequential|dependency-order`, current `cento parallel-delivery patch-swarm integrate RUN_ID --dry-run|--apply`, `cento factory integrate RUN_ID --plan|--apply --validate-each`. + +Inputs: Validated patches, dependency graph, integration strategy, Factory patch bundles. + +Outputs: Integration queue, apply plan, integrated/rejected/conflict ledgers, Safe Integrator receipts. + +Validation commands: Queue order check; apply dry-run; validate each integrated patch; no reset/checkout/clean/stash requirement. + +Evidence files: Safe Integrator handoff, Factory apply plan, integration receipts, rollback metadata. + +Failure handling: Quarantine conflicts, reject nondeterministic ordering, and fail the run only when no safe integration path can satisfy the request. + +Acceptance criteria: Integrated patches have recorded order, validation evidence, and rollback metadata. + +## Milestone 9: Release Candidate Build + +Purpose: Produce a release candidate artifact after integration or an explicit no-op result. + +Owned artifacts: `rc/release-candidate.json`, `rc/build.log`, `rc/validation.log`. + +Likely commands/subcommands: `cento parallel-delivery rc --run RUN_ID`, `cento factory release-candidate RUN_ID`, `cento factory validate-integrated RUN_ID`. + +Inputs: Integration receipts, validation matrix, build/test commands, residual risks. + +Outputs: Release candidate JSON, build log, validation log, operator next action. + +Validation commands: RC schema check; integrated patch count check; final validation command set; evidence completeness check. + +Evidence files: RC receipt, build log, validation log, evidence summary link. + +Failure handling: Fail closed if RC validation fails or if integration evidence is incomplete. + +Acceptance criteria: The RC is inspectable, reproducible, and linked from evidence. + +## Milestone 10: Console / Taskstream Visibility + +Purpose: Show concise operator state without exposing secrets or huge transcripts. + +Owned artifacts: Console API payloads, `ui_state.json`, Taskstream/agent-work summary receipts. + +Likely commands/subcommands: `cento parallel-delivery status --run RUN_ID`, `cento parallel-delivery evidence --run RUN_ID`, current Console `/patch-swarm` API, `cento agent-work` summaries, MCP status calls. + +Inputs: Run state, task counts, leases, validation matrix, integration queue, release candidate status, evidence summary. + +Outputs: Visible fields: run ID, request title, state, counts, leased path summary, validation summary, integration queue status, RC status, evidence path, next action. + +Validation commands: Console API fixture checks; no raw secret values; no direct Taskstream/Redmine DB writes; status matches run artifacts. + +Evidence files: `ui_state.json`, status receipt, Taskstream sync preview or agent-work summary. + +Failure handling: Show degraded/unavailable summaries with artifact paths instead of mutating state directly. + +Acceptance criteria: Operators can understand progress and next action without opening raw transcripts. + +## Milestone 11: E2E Demo Harness + +Purpose: Provide a bounded local demo that proves the full contract with 2-3 tasks. + +Owned artifacts: Example request, fixture patch bundles, demo run directory, final evidence summary. + +Likely commands/subcommands: `cento parallel-delivery demo --request-file examples/parallel-delivery/simple-request.md --max-tasks 3`, current `cento parallel-delivery patch-swarm e2e --candidate-target 30 --max-parallel-agents 3 --fixture --json`, `python3 scripts/patch_swarm_product_e2e.py`. + +Inputs: Simple request fixture, safe patch fixture, unsafe patch fixture, validation commands. + +Outputs: Run directory, ProReq packet, 2-3 tasks, non-overlapping leases, prompts, collected bundles, validation, rejected unsafe patch, integrated safe patch, RC, evidence summary, final status. + +Validation commands: One-command e2e; grep final evidence; JSON schema checks; no selected repo mutation check; docs smoke where relevant. + +Evidence files: Demo summary, commands log, artifacts manifest, RC, validation matrix, status receipt. + +Failure handling: Demo fails if it cannot prove both safe integration and unsafe rejection with evidence. + +Acceptance criteria: A local operator can run the demo repeatedly and get deterministic evidence without live provider spend. diff --git a/docs/patch-swarm-lifecycle.md b/docs/patch-swarm-lifecycle.md new file mode 100644 index 0000000..1836979 --- /dev/null +++ b/docs/patch-swarm-lifecycle.md @@ -0,0 +1,91 @@ +# Patch Swarm Lifecycle + +This lifecycle is the product-level contract for Patch Swarm / Parallel Software Delivery. It maps the logical `workspace/runs//` artifact shape to the current implemented root, `workspace/runs/parallel-delivery/patch-swarm//`, until a future migration changes the physical path. + +```mermaid +flowchart TD + A[Operator product request] --> B[request_received] + B --> C[run_created: run.json and request/request.md] + C --> D[ProReq product request packet: request/proreq.json] + D --> E[plan_generated: Factory decomposition] + E --> F[tasks_created: up to 100 candidate patch tasks] + F --> G[paths_leased: Workset path leases] + G --> H[worker_prompts_emitted: Codex/worker prompts] + H --> I[workers_dispatched: bounded worker execution] + I --> J[patch_bundles_collected: patch bundles and diffs] + J --> K[task_validation_started: Build/Factory deterministic checks] + K -->|pass| L[task_validation_passed] + K -->|fail| M[task_validation_failed] + L --> N[integration_queue_built] + M --> O[patch_rejected with evidence] + N --> P[integration_started: Safe Integrator] + P --> Q[patch_integrated] + P --> R[patch_rejected] + Q --> S[release_candidate_built] + R --> S + S --> T[release_candidate_validated] + T --> U[evidence_written under workspace/runs] + U --> V[Console and Taskstream summaries] + V --> W[run_completed] + C --> X[run_aborted] + E --> X + K --> Y[run_failed] + P --> Y + T --> Y +``` + +## Artifact Handoffs + +| Stage | State or handoff | Artifact owner | Required artifacts | +| --- | --- | --- | --- | +| Intake | `request_received` | Operator and `cento parallel-delivery init` planned facade | `request/request.md` | +| Run creation | `run_created` | Parallel Delivery | `run.json`, `evidence/commands.log` | +| Product requirements | ProReq/product request packet | ProReq/Hard ProReq-compatible packet | `request/proreq.json` | +| Decomposition | Factory task decomposition | Factory or equivalent planner | `plan/decomposition.json`, `plan/task_graph.json`, `plan/risks.json` | +| Task bounding | Up to 100 candidate patch tasks | Parallel Delivery planner | task records with acceptance contracts | +| Path ownership | Workset path leasing | `cento workset` | `leases/path_leases.json` | +| Prompt emission | Codex/worker prompts | Parallel Delivery and Build prompt packet format | `prompts/task-0001.md`, prompt index | +| Worker execution | Bounded worker execution | Codex/Claude/API/fixture workers in isolated paths | `workers//state.json`, transcripts, evidence | +| Candidate collection | Patch bundles | Build/Workset/Patch Swarm normalizer | `patch.bundle.json`, `patch.diff` | +| Validation | Build validation and Factory `validate-fanout` | Build, Factory, deterministic validators | `validation/*.validation.json`, `validation/matrix.json` | +| Integration queue | Dependency or sequential order | Safe Integrator queue builder | `integration/queue.json` | +| Integration | Safe Integrator | Factory/Safe Integrator worktree path | `integrated-patches.json`, `rejected-patches.json`, `conflicts.json` | +| Release candidate | Release candidate | Factory release candidate path | `rc/release-candidate.json`, `rc/build.log`, `rc/validation.log` | +| Evidence | Durable run evidence | Parallel Delivery evidence renderer | `evidence/summary.md`, `evidence/artifacts.json`, `evidence/commands.log` | +| Visibility | Console/Taskstream summaries | Console, MCP, `cento agent-work` | summarized status, evidence path, next action | + +## Required Lifecycle Stages + +The docs and runtime slices must preserve these named stages, either as exact state names or explicit event names: + +```text +request_received +run_created +plan_generated +tasks_created +paths_leased +worker_prompts_emitted +workers_dispatched +patch_bundles_collected +task_validation_started +task_validation_passed +task_validation_failed +integration_queue_built +integration_started +patch_integrated +patch_rejected +release_candidate_built +release_candidate_validated +evidence_written +run_completed +run_failed +run_aborted +``` + +## Terminal Outcomes + +- `run_completed`: release candidate validation passed and durable evidence exists. +- `run_failed`: no safe patch can satisfy the request, release candidate validation fails, or mandatory evidence cannot be written. +- `run_aborted`: operator or admission controller stops the run before completion and records a reason. + +Failed tasks do not necessarily fail a run. They become rejected task evidence when at least one safe patch can still integrate and the release candidate can satisfy the request. diff --git a/docs/patch-swarm-validation-matrix.md b/docs/patch-swarm-validation-matrix.md new file mode 100644 index 0000000..4bfab37 --- /dev/null +++ b/docs/patch-swarm-validation-matrix.md @@ -0,0 +1,49 @@ +# Patch Swarm Validation Matrix + +This matrix defines the future runtime validation contract and the current Call 1 docs validation checks. + +| Area | Scenario | Command / Check | Expected Result | Evidence | +| --- | --- | --- | --- | --- | +| Intake | Request file exists | `cento parallel-delivery init --request-file REQUEST.md` | Run directory created | `run.json` | +| Intake | Missing request file rejected | `cento parallel-delivery init --request-file missing.md` | Fails with no run mutation | `evidence/commands.log` or failure receipt | +| Intake | ProReq packet generated | Inspect `request/proreq.json` | Goal, acceptance, risks, budget, paths, and validation fields exist | `request/proreq.json` | +| Planning | Max 100 tasks | `cento parallel-delivery plan --run RUN_ID --max-tasks 100` | `decomposition.json` has `<= 100` tasks | `plan/decomposition.json` | +| Planning | Over-100 rejected | `cento parallel-delivery plan --run RUN_ID --max-tasks 101` | Fails closed or clamps only with explicit evidence | planner failure receipt | +| Planning | Task acceptance contract required | JSON check on task records | Every task has acceptance criteria | `plan/decomposition.json` | +| Planning | Dependency graph valid | Graph check | No dependency cycles | `plan/task_graph.json` | +| Leasing | Overlapping paths rejected | future fixture with two tasks writing same file | Conflicting leases fail | `leases/path_leases.json` | +| Leasing | Glob write paths rejected | future fixture with `docs/*.md` write path | Workset check fails | Workset check receipt | +| Leasing | Absolute paths rejected | future fixture with `/tmp/outside` write path | Workset check fails | Workset check receipt | +| Leasing | Shared edit serialized | future fixture with shared file pressure | Planner creates serialized integrator task or fails with reason | `plan/risks.json` | +| Prompts | Prompt includes lease | grep prompt for owned/read-only paths | Worker cannot miss path boundary | `prompts/task-0001.md` | +| Prompts | Prompt includes acceptance | grep prompt for acceptance contract | Task cannot dispatch without acceptance | prompt emission receipt | +| Collection | Bundle schema valid | JSON schema check | Patch bundle has task, base, paths, diff, evidence | `workers/task-0001/patch.bundle.json` | +| Collection | Diff exists | `test -f workers/task-0001/patch.diff` | Diff artifact present | `patch.diff` | +| Validation | Changed paths inside lease | diff path check | Patch passes only if all changed paths are leased | `validation/task-0001.validation.json` | +| Validation | Unsafe path rejected | fixture changes outside leased paths | Task becomes `rejected` with reason | rejection receipt | +| Validation | Secret leak rejected | fixture copies `.env.mcp` or API-key-like value | Task rejected and secret not copied into evidence | validation failure receipt | +| Validation | Direct DB writes rejected | fixture attempts Taskstream/Redmine/story DB mutation | Task rejected | validation failure receipt | +| Validation | Required tests evidenced | compare `validation_commands` and `tests_run` | Claimed tests have command output or are marked not run | validation matrix | +| Validation | Dirty work protected | fixture would overwrite unrelated dirty work | Integration blocked before apply | validation failure receipt | +| Integration | Queue recorded | `cento parallel-delivery integrate --run RUN_ID --strategy sequential` | `integration/queue.json` records order | `integration/queue.json` | +| Integration | Dependency order honored | dependency-order fixture | Dependents integrate after prerequisites | queue and integrated patch ledger | +| Integration | No unsafe git operations | inspect apply plan/commands | No required `git reset`, `git checkout`, `git clean`, or `git stash` | integration receipt | +| Integration | Failed task does not fail run when safe patch remains | mixed safe/unsafe fixture | Unsafe rejected, safe integrated | integrated/rejected ledgers | +| Integration | No safe patch fails run | all unsafe fixture | Run reaches `failed` with evidence | `evidence/summary.md` | +| Release candidate | RC written | `cento parallel-delivery rc --run RUN_ID` | `release-candidate.json`, build and validation logs exist | `rc/release-candidate.json` | +| Release candidate | RC requires integration or no-op | run with no integration evidence | RC command fails unless no-op is explicit | RC failure receipt | +| Evidence | Summary written | `cento parallel-delivery evidence --run RUN_ID` | `evidence/summary.md` exists | `evidence/summary.md` | +| Evidence | Artifact index complete | JSON check | Required artifacts are present or marked absent with reason | `evidence/artifacts.json` | +| Evidence | Secrets excluded | grep evidence for blocked secret paths/tokens | No `.env.mcp`, API keys, or local secret values | evidence scan receipt | +| Console | Status payload complete | `cento parallel-delivery status --run RUN_ID --json` | Summary includes run ID, state, counts, leases, validation, queue, RC, evidence path, next action | status receipt | +| Taskstream | Uses safe surfaces | inspect sync/agent-work command path | Status is published through MCP or `cento agent-work`, not direct DB writes | Taskstream sync preview | +| Demo | Bounded e2e | `cento parallel-delivery demo --request-file examples/parallel-delivery/simple-request.md --max-tasks 3` | Proves intake, planning, leases, prompts, collection, validation, rejection, integration, RC, evidence, status | demo evidence summary | +| Existing runtime | Current Patch Swarm fixture e2e | `cento parallel-delivery patch-swarm e2e --candidate-target 30 --max-parallel-agents 3 --fixture --json` | Current implementation proves candidate generation and Safe Integrator handoff without main-worktree mutation | current Patch Swarm run evidence | +| Existing runtime | Current Factory validation fanout | `cento factory validate-fanout RUN_ID --max-parallel 32 --json` | Candidate checks run in parallel before serialized Safe Integrator apply | Factory validation receipt | +| Existing runtime | Current Workset checker | `cento workset check WORKSET` | Overlap, glob, absolute, and missing write path issues are rejected | Workset check receipt | +| Current Call 1 | docs exist | `test -f docs/patch-swarm.md && test -f docs/patch-swarm-lifecycle.md && test -f docs/patch-swarm-implementation-map.md && test -f docs/patch-swarm-validation-matrix.md` | Canonical spec and support docs exist | `workspace/runs/patch-swarm-call-1-product-architecture/validation.log` | +| Current Call 1 | required headings exist | `rg -n "Product Definition|Operator Story|User-Facing CLI Contract|Artifact Lifecycle|Run State Machine|Worker / Task State Machine|What .100 Agents. Means Safely|Unsafe Inputs and Rejection Rules|E2E Demo Definition of Done" docs/patch-swarm.md` | All required headings found | validation log | +| Current Call 1 | lifecycle diagram exists | `rg -n "flowchart TD|request_received|run_completed|run_failed|run_aborted" docs/patch-swarm-lifecycle.md` | Diagram and terminal states found | validation log | +| Current Call 1 | unsafe rejection rules exist | `rg -n "change files outside their leased paths|copy .env.mcp|direct database writes|overwrite unrelated dirty work|git reset|delete durable evidence" docs/patch-swarm.md` | Unsafe rules found | validation log | +| Current Call 1 | implementation map exists | `rg -n "Milestone 0: Canonical Spec and Docs|Milestone 11: E2E Demo Harness" docs/patch-swarm-implementation-map.md` | Future slices are documented | validation log | +| Current Call 1 | evidence files exist | `test -f workspace/runs/patch-swarm-call-1-product-architecture/discovery.log` and related checks | Discovery, docs list, summary, and validation logs exist | run evidence directory | diff --git a/docs/patch-swarm.md b/docs/patch-swarm.md new file mode 100644 index 0000000..aeb17ec --- /dev/null +++ b/docs/patch-swarm.md @@ -0,0 +1,712 @@ +# Patch Swarm / Parallel Software Delivery Product Spec + +Patch Swarm is the Cento-native MVP for cost-effective, massively parallel AI development. It creates many cheap candidate patches, validates and ranks them deterministically, and allows only one serialized integration execution to hand winners to the Safe Integrator path. + +The current implementation is fixture-first by default. Live `api-openai` execution is fail-closed behind explicit budget caps, `OPENAI_API_KEY`, and a bounded sandbox candidate limit. Applying selected winners is routed through Factory/Safe Integrator worktrees, not the main worktree. + +This is the canonical product architecture document for the Patch Swarm / Parallel Software Delivery system. Supporting lifecycle, implementation, and validation details live in: + +- [Patch Swarm Lifecycle](./patch-swarm-lifecycle.md) +- [Patch Swarm Implementation Map](./patch-swarm-implementation-map.md) +- [Patch Swarm Validation Matrix](./patch-swarm-validation-matrix.md) +- [Patch Swarm Artifact Schema](./parallel-delivery/patch-swarm-artifacts.md) +- [Patch Swarm Request Splitter and 100-Task Planner](./parallel-delivery/patch-swarm-planner.md) +- [Patch Swarm ProReq and ChatGPT Pro Prompt Bundles](./parallel-delivery/patch-swarm-proreq-prompts.md) +- [Patch Swarm Path Leasing and Workset Compatibility](./parallel-delivery/patch-swarm-leasing.md) +- [Patch Swarm Worker Pool and Process Visibility](./parallel-delivery/patch-swarm-worker-status.md) +- [Patch Swarm Deterministic Validation and Fixture E2E](./parallel-delivery/patch-swarm-validation-e2e.md) +- [Patch Swarm Taskstream Handoff](./parallel-delivery/patch-swarm-taskstream.md) +- [Patch Swarm Console Status](./parallel-delivery/patch-swarm-console.md) +- [Patch Swarm Patch Bundle Collection and Safety Validation](./parallel-delivery/patch-bundle-validation.md) +- [Parallel Delivery Safe Apply And Release Candidate](./parallel-delivery/release-candidate-safe-apply.md) +- [Fixture Sickness Reuse Gate](./fixture-sickness.md) + +## Product Definition + +Patch Swarm is Cento's local-first parallel software delivery system. Given one high-level product request, it creates a run, converts the request into a ProReq/product request packet, splits it into up to 100 bounded candidate patch tasks, leases exclusive paths with Workset, emits Codex/worker prompts, collects patch bundles, validates them deterministically through Build and Factory checks, integrates safe patches sequentially or by dependency order through the Safe Integrator path, produces a release candidate, and writes durable evidence under `workspace/runs/`. + +Patch Swarm is not unbounded multi-agent editing. It is candidate generation plus controlled integration. Workers may generate candidate diffs or structured artifacts in parallel, but repository mutation stays behind deterministic validation, recorded ordering, and Factory/Safe Integrator apply gates. + +The concrete existing run root is `workspace/runs/parallel-delivery/patch-swarm//`. The product contract below uses `workspace/runs//` as the logical shape for future operators and docs; implementation slices must either map that logical shape to the existing run root or migrate with compatibility receipts. + +## Operator Story + +Given one product request, Cento creates a run, splits the request into up to 100 bounded candidate patch tasks, leases exclusive paths, emits Codex/worker prompts, collects patch bundles, validates them deterministically, integrates safe patches sequentially or by dependency order, produces a release candidate, and writes durable evidence. + +The operator should be able to ask for one product outcome, watch candidate and integration state in Console/Taskstream summaries, inspect the release candidate and evidence, and continue from the recorded next action without reading raw worker transcripts by default. + +## Non-Goals + +- No unbounded concurrent writers against the same worktree. +- No bypass around Factory, Workset, Build, or Safe Integrator when those surfaces already own the behavior. +- No direct Taskstream, Redmine, or story database writes. +- No live provider fanout without explicit budget caps, provider receipts, and fail-closed admission checks. +- No automatic merge to main in the Patch Swarm product contract. +- No registry claim that planned commands exist until the runtime implements them. + +## Existing Cento Surfaces Used + +| Surface | Role in Patch Swarm | +| --- | --- | +| `cento parallel-delivery` | Registered orchestration surface for run creation, Patch Swarm planning, execution, validation, status, and evidence. | +| `cento parallel-delivery patch-swarm` | Current implemented command family for fixture/live candidate generation, ranking, Safe Integrator handoff, validation, status, and e2e proof. | +| `cento factory` | Durable planning, patch collection, `validate-fanout`, Safe Integrator apply plans, release candidates, release evidence, rollback, and Taskstream sync previews. | +| `cento workset` | Exclusive write path checks, worker boundaries, dependency gates, parallel artifact collection, and sequential integration discipline. | +| `cento build` | Manifest-owned local build packages, worker prompts, worker artifact checks, patch bundles, safe apply receipts, and deterministic patch safety checks. | +| Taskstream | Operator-visible status and summaries through existing MCP or `cento agent-work` surfaces; never direct DB mutation. | +| `cento mcp` | Repo-local MCP server for safe board, story, cluster, bridge, and agent-work context when an MCP client is available. | +| ProReq | Product request packet and requirements contract that constrains decomposition, acceptance, paths, validation, risk, and budget. | +| Safe Integrator | The only real patch apply boundary after validation; applies selected bundles in recorded sequential or dependency order. | +| `cento agent-work` | Existing agent-visible work/status bridge when work needs durable operator or Taskstream visibility. | +| Console visibility | Shows run summaries, candidates, gates, validation, decisions, release candidate status, and evidence links without exposing secrets or huge transcripts. | + +## User-Facing CLI Contract + +The existing implemented surface is `cento parallel-delivery patch-swarm ...`. The target operator facade below is the planned product contract for future implementation slices. Until implemented, these commands are documented as planned contract, and implementation must route to existing `parallel-delivery`, `factory`, `workset`, and `build` behavior instead of creating a competing scheduler. + +```bash +cento parallel-delivery init --request-file REQUEST.md [--run-id RUN_ID] +cento parallel-delivery plan --run RUN_ID --max-tasks 100 +cento parallel-delivery emit-prompts --run RUN_ID --out workspace/runs/RUN_ID/prompts +cento parallel-delivery collect --run RUN_ID --patch-dir workspace/runs/RUN_ID/inbox +cento parallel-delivery validate --run RUN_ID +cento parallel-delivery integrate --run RUN_ID [--strategy sequential|dependency-order] +cento parallel-delivery rc --run RUN_ID +cento parallel-delivery status --run RUN_ID +cento parallel-delivery evidence --run RUN_ID +cento parallel-delivery demo --request-file examples/parallel-delivery/simple-request.md --max-tasks 3 +``` + +| Subcommand | Purpose | Inputs | Outputs | State transition | Evidence written | Failure behavior | +| --- | --- | --- | --- | --- | --- | --- | +| `init` | Create a run from an operator request and normalize it into the ProReq packet. | `REQUEST.md`, optional `RUN_ID`. | `run.json`, `request/request.md`, `request/proreq.json`. | `request_received -> run_created`. | `evidence/commands.log`, request receipt. | Reject missing request files, unsafe paths, or duplicate run IDs unless resume is explicit. | +| `plan` | Decompose the ProReq packet into bounded candidate tasks and graph edges. | `run.json`, `proreq.json`, `--max-tasks` up to 100. | `plan/decomposition.json`, `plan/task_graph.json`, `plan/risks.json`. | `run_created -> plan_generated -> tasks_created`. | Planner receipt and risks. | Reject over-100 tasks, missing acceptance contracts, duplicate workflow attempts, and unresolved shared-path ambiguity. | +| `emit-prompts` | Emit worker-ready Codex/worker prompt packets after leases are valid. | Task graph, path leases, output directory. | `prompts/task-0001.md`, prompt index. | `paths_leased -> worker_prompts_emitted`. | Prompt emission receipt. | Reject tasks without acceptance contracts, validation commands, or exclusive path lease/read-only classification. | +| `collect` | Collect submitted patch bundles from workers or fixture inboxes. | Inbox path with bundle JSON and diff files. | `workers//patch.bundle.json`, `workers//patch.diff`, transcript/evidence links. | `workers_dispatched -> patch_bundles_collected`. | Collection receipt and per-task state updates. | Reject malformed schemas, missing diffs, stale base metadata, or changed paths outside claimed paths. | +| `validate` | Run deterministic validation for each submitted patch bundle. | Run directory and validation commands. | `validation/*.validation.json`, `validation/matrix.json`. | `patch_bundles_collected -> validation_started -> validation_passed|validation_failed`. | Validation logs and rejection reasons. | Reject unsafe diffs, missing evidence, skipped required checks, secret leaks, direct DB mutation, and path lease violations. | +| `integrate` | Build and execute the Safe Integrator queue in recorded order. | Validated tasks, strategy `sequential` or `dependency-order`. | `integration/queue.json`, integrated/rejected/conflict ledgers. | `validation_passed -> integration_started -> integration_completed`. | Integration receipt and ordering ledger. | Reject nondeterministic ordering, conflicts without quarantine, dirty unrelated work, or patches requiring reset/checkout/clean/stash. | +| `rc` | Build the release candidate from integrated patches or an explicit no-op result. | Integration receipts and validation matrix. | `rc/release-candidate.json`, `rc/build.log`, `rc/validation.log`. | `integration_completed -> rc_built -> rc_validated`. | RC receipt and validation log. | Fail if integration is incomplete, RC validation fails, or evidence is missing. | +| `status` | Report current run state and operator-visible summary. | `RUN_ID`. | Human or JSON status summary. | No mutation except optional status read receipt. | Optional status receipt. | Return failed/unknown with missing artifact paths instead of guessing. | +| `evidence` | Render the durable evidence summary and artifact index. | Run directory. | `evidence/summary.md`, `evidence/artifacts.json`, `evidence/commands.log`. | `rc_validated -> completed` when evidence is durable. | Evidence summary and artifact manifest. | Do not mark completed if evidence cannot be written. | +| `demo` | Future compact e2e proof using 2-3 bounded tasks. | Example request, `--max-tasks 3`. | Full run directory and final status. | Full lifecycle to `completed` or explicit `failed`. | Demo summary and receipts. | Must include one unsafe rejection and one safe integration or fail with concrete evidence. | + +## Artifact Lifecycle + +The minimum logical run shape is: + +```text +workspace/runs// + run.json + request/ + request.md + proreq.json + plan/ + decomposition.json + task_graph.json + risks.json + leases/ + path_leases.json + prompts/ + task-0001.md + task-0002.md + workers/ + task-0001/ + state.json + transcript.md + patch.bundle.json + patch.diff + evidence/ + task-0002/ + state.json + transcript.md + patch.bundle.json + patch.diff + evidence/ + validation/ + task-0001.validation.json + task-0002.validation.json + matrix.json + integration/ + queue.json + integrated-patches.json + rejected-patches.json + conflicts.json + rc/ + release-candidate.json + build.log + validation.log + evidence/ + summary.md + commands.log + artifacts.json +``` + +Minimum `run.json` schema: + +```json +{ + "run_id": "patch-swarm-YYYYMMDD-HHMMSS-slug", + "request_title": "string", + "state": "request_received|run_created|plan_generated|tasks_created|paths_leased|worker_prompts_emitted|workers_dispatched|patch_bundles_collected|validation_started|validation_passed|validation_failed|integration_started|integration_completed|rc_built|rc_validated|completed|failed|aborted", + "created_at": "ISO-8601", + "updated_at": "ISO-8601", + "max_candidate_tasks": 100, + "max_concurrent_workers": 1, + "integration_strategy": "sequential|dependency-order", + "artifacts": {} +} +``` + +Minimum task schema: + +```json +{ + "task_id": "task-0001", + "title": "string", + "state": "created|leased|prompt_emitted|dispatched|patch_submitted|validation_running|validation_passed|validation_failed|queued_for_integration|integrated|rejected|superseded|aborted", + "owned_paths": [], + "read_only_paths": [], + "dependencies": [], + "acceptance_contract": [], + "validation_commands": [], + "patch_bundle": null, + "evidence": [] +} +``` + +Minimum patch bundle schema: + +```json +{ + "task_id": "task-0001", + "base_ref": "string", + "worker_id": "string", + "claimed_paths": [], + "changed_paths": [], + "diff_path": "patch.diff", + "summary": "string", + "tests_run": [], + "evidence_files": [], + "risks": [], + "requires_manual_review": false +} +``` + +## Run State Machine + +Allowed run states: + +```text +request_received +run_created +plan_generated +tasks_created +paths_leased +worker_prompts_emitted +workers_dispatched +patch_bundles_collected +validation_started +validation_passed +validation_failed +integration_started +integration_completed +rc_built +rc_validated +completed +failed +aborted +``` + +Allowed transitions: + +```text +request_received -> run_created +run_created -> plan_generated +plan_generated -> tasks_created +tasks_created -> paths_leased +paths_leased -> worker_prompts_emitted +worker_prompts_emitted -> workers_dispatched +workers_dispatched -> patch_bundles_collected +patch_bundles_collected -> validation_started +validation_started -> validation_passed +validation_started -> validation_failed +validation_passed -> integration_started +validation_failed -> integration_started when at least one patch passed validation +validation_failed -> failed when no safe patch remains and the request cannot be satisfied +integration_started -> integration_completed +integration_completed -> rc_built +rc_built -> rc_validated +rc_validated -> completed +any nonterminal state -> failed with evidence +any nonterminal state -> aborted with operator reason +``` + +Rules: + +- A run cannot enter `workers_dispatched` until prompts exist. +- A run cannot enter `integration_started` until at least one patch has passed validation. +- A run cannot enter `rc_built` until integration has completed or the queue is empty with an explicit no-op result. +- A run cannot enter `completed` until durable evidence exists. +- Failed tasks do not necessarily fail the run if at least one safe patch can be integrated. +- The run fails if no patch can be integrated and the request cannot be satisfied. + +## Worker / Task State Machine + +Allowed task states: + +```text +created +leased +prompt_emitted +dispatched +patch_submitted +validation_running +validation_passed +validation_failed +queued_for_integration +integrated +rejected +superseded +aborted +``` + +Allowed transitions: + +```text +created -> leased +leased -> prompt_emitted +prompt_emitted -> dispatched +dispatched -> patch_submitted +patch_submitted -> validation_running +validation_running -> validation_passed +validation_running -> validation_failed +validation_passed -> queued_for_integration +queued_for_integration -> integrated +queued_for_integration -> rejected +validation_failed -> rejected +created|leased|prompt_emitted|dispatched -> aborted +any nonterminal task -> superseded when a newer valid task replaces it with evidence +``` + +Rules: + +- A task cannot become `prompt_emitted` without an acceptance contract. +- A task cannot become `dispatched` without an exclusive path lease or explicit read-only classification. +- A task cannot become `queued_for_integration` unless validation passed. +- A task cannot become `integrated` if it changes paths outside its lease. +- A task with a stale base, unsafe diff, secret leak, or direct Taskstream DB mutation must be rejected. +- A rejected task must retain evidence explaining the rejection. + +## What "100 Agents" Means Safely + +"100 agents" means: + +```text +up to 100 candidate patch tasks ++ bounded concurrent execution ++ exclusive path leases ++ deterministic validation before integration ++ sequential or dependency-ordered safe integration ++ evidence-backed acceptance +``` + +It does not mean 100 unbounded concurrent writers. Patch Swarm may generate up to 100 candidate tasks or candidate patches, but each task must be bounded by an owned path set, read-only path set, acceptance contract, validation command set, and integration strategy. The default `max_concurrent_workers` may be as low as `1` for safety; higher concurrency is allowed only when Workset proves non-overlapping write paths and the budget/admission gates pass. + +## Path Leasing and Workset Boundaries + +Workset is the path boundary authority. A task lease must list exact owned paths, read-only paths, dependencies, blocked paths, and any generated artifact destinations. Globs, absolute paths, overlapping write paths, protected files, and shared-file edits are rejected unless the shared edit is moved into an explicit serialized integrator task. + +Workers can read context outside their owned paths only when the planner marks those files read-only. They cannot write run artifacts outside the run root, cannot mutate unrelated dirty work, and cannot create new persistent tool or CLI registry entries unless the task explicitly owns those files and the schema/docs alignment checks pass. + +## Planning and Prompt Packet Generation + +Planning starts from the ProReq/product request packet. The Factory-equivalent decomposition produces task records with title, owned paths, read-only paths, dependencies, acceptance contract, validation commands, risks, and prompt constraints. Prompt packets are emitted only after path leases exist and must include: + +- the operator request summary, +- the task acceptance contract, +- owned and read-only paths, +- validation commands to run or explain, +- patch bundle schema, +- explicit unsafe rejection rules, +- the run evidence directory, +- instructions to preserve unrelated dirty work. + +Codex-ready worker packets are generated by `cento parallel-delivery patch-swarm worker-packets` and documented in `docs/parallel-delivery/patch-swarm-codex-worker-packets.md`. They are local copy/paste artifacts, not live dispatch. + +Bounded worker dispatch/status planning is generated by `cento parallel-delivery patch-swarm dispatch --dry-run` and documented in `docs/parallel-delivery/patch-swarm-worker-status.md`. It represents up to 100 candidate tasks, writes queue and status ledgers, and keeps process visibility local/read-only unless a future live backend is explicitly validated. + +## Patch Bundle Contract + +Patch bundles are structured receipts, not free-form chat summaries. A bundle must include the task ID, worker ID, base ref, claimed paths, changed paths, diff path, summary, tests run, evidence files, risks, and manual-review flag. The diff must apply to the recorded base in an isolated validation path or be rejected as stale. + +Workers may submit `candidate_patch.v1` receipts through the current Patch Swarm path, Workset worker artifacts, or Build patch bundles. The Safe Integrator handoff must normalize accepted candidates into Factory-compatible patch bundles before any real apply attempt. + +## Deterministic Validation Contract + +Validation is deterministic first. The validator checks schema, base ref, path lease compliance, diff safety, secret patterns, direct DB mutation attempts, required command evidence, changed path ownership, dependency status, and focused tests. `cento build` and `cento factory validate-fanout` are the preferred validation surfaces where their contracts fit. AI review is advisory only and must be converted back into deterministic receipts before integration. + +## Safe Integration Contract + +Safe integration is serialized or dependency ordered. The integration queue records every patch candidate, ordering reason, dependency edge, apply command, validation command, and result. Integration applies only validated bundles through Factory/Safe Integrator worktrees or equivalent receipts. It must reject nondeterministic ordering, dirty unrelated work, missing rollback metadata, and any apply path that requires `git reset`, `git checkout`, `git clean`, or `git stash` to succeed. + +## Release Candidate Contract + +A release candidate is a recorded candidate release state, not a merge. It must include integrated patch IDs, rejected patch IDs, validation summary, build log, residual risks, rollback notes, evidence paths, and the next operator action. The release candidate can be a no-op only when the integration queue is empty and the run records why no safe patch was needed or possible. + +The implemented local receipt path is `cento parallel-delivery release-candidate create`. It reads an accepted `cento.parallel_delivery.integration_receipt.v1`, verifies each accepted bundle receipt and patch SHA-256, refuses rejected or non-integratable bundles, dry-runs every patch by default, and writes apply receipts plus rollback metadata. `--mode apply` requires an explicit isolated target worktree and writes `release-candidate.json`, `release-notes.md`, and `integrated.diff` only after all accepted bundles apply sequentially and final validation passes. + +## Evidence Contract + +Every run writes durable evidence under `workspace/runs/`. Minimum evidence includes command logs, artifact index, summary markdown, state transitions, validation matrix, rejection reasons, integration queue, release candidate, and Console/Taskstream summary path. Evidence must avoid secrets, raw environment dumps, API keys, `.env.mcp`, and huge raw transcripts unless a transcript is stored as a run artifact and summarized safely. + +## Console and Taskstream Visibility + +Console and Taskstream should show summaries, not raw secrets or huge transcripts. The visible summary fields are: + +- `run_id` +- request title +- current run state +- candidate task count +- active worker count +- passed, failed, rejected, and integrated task counts +- leased path summary +- validation summary +- integration queue status +- release candidate status +- evidence summary path +- next operator action + +Cento may publish status through existing MCP or `cento agent-work` surfaces. Cento must not mutate Taskstream, Redmine, or story state through direct DB writes. + +## Unsafe Inputs and Rejection Rules + +Patch Swarm must deterministically reject inputs or patches that: + +- change files outside their leased paths +- modify secrets or include local secret values +- copy `.env.mcp` or API keys into repo artifacts +- copy .env.mcp through any transcript, evidence file, or generated artifact path +- attempt direct database writes to Taskstream/Redmine/story state +- skip required validation commands +- claim tests passed without evidence +- overwrite unrelated dirty work +- require `git reset`, `git checkout`, `git clean`, or `git stash` to apply +- modify `data/tools.json` or `data/cento-cli.json` without matching existing schema and docs +- introduce duplicate workflows instead of using existing Cento surfaces +- use nondeterministic integration without recorded ordering +- change generated run artifacts outside `workspace/runs//` +- delete durable evidence + +## E2E Demo Definition of Done + +The end-to-end demo is done when a local operator can run a bounded demo proving: + +- one request creates a run directory +- the request is converted into a ProReq/product request packet +- Factory or an equivalent planner creates 2-3 bounded tasks +- Workset creates non-overlapping path leases +- worker prompts are emitted +- at least one synthetic or real patch bundle is collected +- validation runs deterministically +- an unsafe patch is rejected with evidence +- a safe patch is integrated in recorded order +- a release candidate artifact is written +- summary evidence is written under `workspace/runs//evidence/` +- `status` reports the final state + +For Call 1, done means this canonical spec, lifecycle diagram, implementation map, validation matrix, and run evidence exist and pass grep/docs-command validation. + +## Future Implementation Calls + +Future implementation should proceed in slices: + +1. Run directory and artifact schema. +2. Request intake and ProReq packet generation. +3. Factory task splitter. +4. Workset path leasing. +5. Worker prompt packet emission. +6. Patch bundle collection. +7. Deterministic task validation. +8. Safe Integrator queue and apply handoff. +9. Release candidate build. +10. Console/Taskstream summary visibility. +11. Bounded e2e demo harness. + +## Command Surface + +```bash +cento parallel-delivery patch-swarm plan --candidate-target 100 --max-parallel-agents 5 --json +cento parallel-delivery patch-swarm execute RUN_ID --fixture --json +cento parallel-delivery patch-swarm execute RUN_ID --live --budget-cap-usd 1 --max-budget-usd 1 --api-sandbox-candidates 1 --json +cento parallel-delivery patch-swarm integrate RUN_ID --dry-run --json +cento parallel-delivery patch-swarm integrate RUN_ID --apply --factory-run workspace/runs/factory/patch-swarm-RUN_ID --validate-each --json +cento parallel-delivery patch-swarm validate RUN_ID --json +cento parallel-delivery patch-swarm status RUN_ID --json +cento parallel-delivery patch-swarm e2e --candidate-target 30 --max-parallel-agents 3 --fixture --json +cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json +``` + +Walk Autopilot can also run or inspect the dry-run coordinator: + +```bash +cento walk-autopilot patch-swarm run --candidate-target 100 --max-parallel-agents 5 --json +cento walk-autopilot patch-swarm status --json +``` + +The regular Walk Autopilot loop can include one Patch Swarm fixture e2e per loop with `--patch-swarm`. + +## Architecture + +Patch Swarm always uses ten ProReq execution lanes plus one dedicated integrator execution. + +The ten lanes are: + +- `request-decomposer` +- `codex-exec-adapter` +- `claude-code-adapter` +- `openai-patch-proposal-adapter` +- `candidate-normalizer` +- `dedupe-clustering` +- `deterministic-validator-fanout` +- `cost-latency-ledger` +- `dev-pipeline-studio-ui` +- `autopilot-coordinator-hooks` + +Providers are normalized into `candidate_patch.v1` receipts: + +- `codex-exec`: local command runtime using the existing `codex-fast` profile. +- `claude-code`: local command runtime using the new `claude-code-fast` profile. +- `api-openai`: structured API worker path using `patch_proposal.v1`. + +The fixture e2e writes patch diff artifacts, validates them, clusters duplicates, ranks winners, selects one winner per ProReq lane, and writes `safe_integrator_handoff.json`. Candidate targets can be small for sandbox validation or larger for scale tests. + +When `integrate --apply` or `--factory-run` is used, Patch Swarm converts selected `candidate_patch.v1` receipts into Factory patch bundles, writes a Factory apply plan, runs Factory `validate-fanout`, and only then attempts Safe Integrator worktree apply. + +## UI Integration + +Patch Swarm now has a standalone Cento Console module at: + +- `/patch-swarm` +- `/patch-swarm/runs/:run_id` + +The product UI is separate from Dev Pipeline Studio. It provides local Git repo discovery, a task composer, run history, candidate review, diff preview, winner approval, rejection notes, and a supervised apply button. Dev Pipeline Studio remains the advanced diagnostics/configuration view for the underlying pipeline template. + +The local HTTP API is: + +```text +GET /api/patch-swarm/repos +GET /api/patch-swarm/runs +POST /api/patch-swarm/runs +GET /api/patch-swarm/runs/:run_id +POST /api/patch-swarm/runs/:run_id/approve +POST /api/patch-swarm/runs/:run_id/reject +POST /api/patch-swarm/runs/:run_id/apply +``` + +Run list and detail payloads include `run_kind`, which is `product` for local repo product runs and `engine` for lower-level Patch Swarm pipeline runs. Detail payloads also include `action_gates`: + +- `can_approve` plus `approve_disabled_reason` +- `can_apply` plus `apply_disabled_reason` +- `can_reject` plus `reject_disabled_reason` + +The frontend renders review actions from these backend gates. The backend still enforces the same gates on approve, reject, and apply requests. + +Product runs add thin metadata around the existing artifact contract: + +- `product_metadata.json` +- `product_run_create_receipt.json` +- `supervised_approval.json` +- `candidate_decisions.json` +- `product_safe_integrator_apply.json` for Patch Swarm product worktree apply receipts +- `product_no_mutation_create.json`, `product_no_mutation_apply.json`, and `product_no_mutation_checks.json` + +Patch Swarm is also still registered as a Dev Pipeline Studio template. + +The run writes: + +- `workspace/runs/parallel-delivery/patch-swarm//ui_state.json` +- `workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/patch-swarm/latest_ui_state.json` + +The UI state includes candidate totals, provider counts, lane status, ranking, selected winners, validation, cost ledger, handoff links, product metadata, approval state, and candidate decisions. + +## Cost And Safety + +The default product path is deterministic fixture execution. It does not call OpenAI, does not launch live Codex or Claude workers, and does not apply patches to the selected repo worktree. Product apply creates or reuses a Patch Swarm-owned product worktree under `workspace/runs/patch-swarm-product-worktrees/` and writes no-mutation receipts for the selected repo. + +Live execution should stay behind explicit budget gates. The architecture requires provider receipts and cost ledgers before integration. The integrator remains serialized, and Safe Integrator handoff is the boundary before any real apply path. + +Live Patch Swarm execution requires: + +- a live-enabled plan, +- `--budget-cap-usd` or `--budget-cap`, +- estimated provider spend below the cap, +- hard cap at or below the rollout ceiling, +- `OPENAI_API_KEY` when the `api-openai` sandbox limit is greater than zero. + +Each execution writes `usage_guard.json`, `provider_usage.jsonl`, and `candidate_spend_ledger.jsonl`. If any gate fails, no provider command is launched and the run records a blocked receipt. + +## Validation Evidence + +The first MVP e2e run was: + +```bash +cento parallel-delivery patch-swarm e2e --run-id patch-swarm-e2e-20260505 --candidate-target 100 --max-parallel-agents 5 --providers codex-exec,claude-code,api-openai --fixture --json +``` + +It produced: + +- `workspace/runs/parallel-delivery/patch-swarm/patch-swarm-e2e-20260505/patch_swarm_manifest.json` +- `workspace/runs/parallel-delivery/patch-swarm/patch-swarm-e2e-20260505/candidate_index.json` +- `workspace/runs/parallel-delivery/patch-swarm/patch-swarm-e2e-20260505/integration_execution/integration_execution.json` +- `workspace/runs/parallel-delivery/patch-swarm/patch-swarm-e2e-20260505/safe_integrator_handoff.json` +- `workspace/runs/parallel-delivery/patch-swarm/patch-swarm-e2e-20260505/validation_summary.json` +- `workspace/runs/parallel-delivery/patch-swarm/patch-swarm-e2e-20260505/decision_report.md` + +Result: 100 candidates, 10 ProReq executions, 10 selected winners, validation passed. Fixture runs record zero metered API spend. + +The product release-candidate e2e command is: + +```bash +python3 scripts/patch_swarm_product_e2e.py +``` + +It creates clean, unprotected-dirty, and protected-dirty fixture repos, starts the Cento Console in-process, drives the `/api/patch-swarm/*` product lifecycle, checks no selected-repo mutation, applies one approved candidate in a Patch Swarm product worktree, and captures `/patch-swarm` plus `/patch-swarm/runs/:run_id` screenshots at `390x900`, `1365x1000`, and `2048x1000`. The summary is written to `workspace/runs/patch-swarm-product-e2e//summary.json`. + +## What Patch Swarm Is + +Patch Swarm is the Parallel Delivery path for turning one product request into bounded implementation candidates, validating the candidates with local deterministic gates, and integrating only receipt-backed results. It is centered on the existing `cento parallel-delivery` command family and reuses Build, Workset, Factory, Taskstream, and Console surfaces rather than creating a second scheduler. + +The operator-facing output is not a raw worker transcript. A healthy run produces a request artifact, split plan, task graph, path leases, worker packets, patch bundle receipts, integration plan, validation summary, release candidate, status payload, and evidence index under `workspace/runs/`. + +## Safe Mental Model + +- A target of 100 means up to 100 candidate tasks in the plan, not 100 uncontrolled live writers. +- `--max-parallel-agents` bounds active fixture or worker batches. +- Workset-compatible path leases keep write ownership explicit before workers touch code. +- Patch bundles are collected and validated before integration. +- Integration remains sequential or dependency ordered and is recorded in receipts. +- Fixture mode is the default adoption gate; live providers, live workers, and live Taskstream mutation stay explicit opt-ins. +- Dirty work is preserved. Patch Swarm should inspect and classify dirty targets instead of wiping the worktree. + +## Quickstart + +Run the local fixture gate from the repo root: + +```bash +cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json +cento parallel-delivery validate --json +cento parallel-delivery status --json +``` + +Then inspect the emitted run directory from the JSON payload. The key files are `validation-summary.json`, `validation-report.md`, `integration/integration-plan.json`, `integration/conflict-report.md`, and `release-candidate/release-candidate.json`. + +## Full Fixture Demo + +The full deterministic demo is: + +```bash +cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json +``` + +Expected behavior: + +- 100 candidate tasks are planned. +- Worker batches are bounded to 5 at a time. +- One unsafe fixture bundle is rejected. +- Accepted bundles are ordered in `integration/integration-plan.json`. +- No live provider call, live worker launch, selected-repo mutation, or Taskstream mutation occurs. +- Durable evidence is written under `workspace/runs/parallel-delivery/e2e-fixture//`. + +## ChatGPT Pro / ProReq Flow + +ProReq and ChatGPT Pro prompts are generated as local prompt bundles. The operator can review or paste them manually, but prompt generation does not require a live provider by default. + +```bash +cento parallel-delivery patch-swarm prompts --run-dir workspace/runs/parallel-delivery/proreq-fixture --count 20 --lane all --json +``` + +Each prompt should include mission, owned paths, prohibited paths or safety rules, validation commands, evidence expectations, and a Codex output contract. The generated prompt bundle can be connected to `cento temp` when the existing temp bridge is available. + +## Codex Paste Flow + +Codex worker packets are generated from a split plan and task graph. Each packet is intended for one Codex thread or worker lane and should be pasted only after the operator has reviewed path ownership and validation requirements. + +Typical packet contents: + +- thread title +- task ID +- mission +- discovery commands +- owned write paths +- read-only paths +- prohibited paths +- implementation steps +- tests and validation commands +- evidence paths +- patch bundle or handoff instructions +- blocker protocol + +## Worker Packet Format + +Worker packets must be scoped enough for an agent to act without inventing workflow. A worker packet is valid only when it names the task, lease, expected touched paths, acceptance contract, validation commands, evidence path, and safety constraints. It must not instruct a worker to wipe or broadly restore the worktree, copy secret files, or mutate Taskstream/Redmine outside the approved `cento agent-work` or MCP surfaces. + +## Artifacts and Evidence + +The fixture and product paths both converge on the same evidence model: + +- `run.json`: run identity, state, constraints, provenance, and artifact pointers. +- `request.md` and `context-pack.json`: request and local context. +- `split-plan.json` and `task-graph.json`: bounded task plan and dependencies. +- `path-leases.json`: write ownership and guarded paths. +- `worker-packets/`: paste-ready worker instructions. +- `patch-bundles/` and validation receipts: accepted, rejected, and evidence-only results. +- `integration/integration-plan.json`: deterministic ordering and conflict buckets. +- `integration/conflict-report.md`: human-readable conflict triage. +- `validation-summary.json` and `validation-report.md`: final gate results. +- `release-candidate/release-candidate.json`: release packet status and evidence pointers. + +## Safety Rules + +- Keep fixture and dry-run behavior as the default. +- Require explicit operator flags for live provider fanout, live worker launch, and live Taskstream creation. +- Use existing Build, Workset, Factory, Taskstream, MCP, and `agent-work` surfaces. +- Reject or flag absolute paths, traversal paths, protected local secret paths, undeclared deletes, unowned renames, unsupported binary patches, and broad lockfile edits. +- Preserve unrelated dirty work and record dirty-target risk in evidence. +- Never copy local secret files or token values into prompts, bundles, docs, or evidence. + +## Validation + +The adoption gate is: + +```bash +python3 -m json.tool data/tools.json +cento tools +cento docs parallel-delivery +python3 -m pytest -q tests/test_patch_swarm.py +python3 -m pytest -q tests -k "patch_swarm or parallel_delivery or build or workset or factory or cli or registry or docs" +cento parallel-delivery validate --json +cento parallel-delivery status --json +cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json +``` + +If `make patch-swarm-check` exists in the local Makefile, it should be a wrapper around deterministic local gates only. + +## Console/status Visibility + +Status JSON and Console should show the current run, candidate count, active or simulated worker batches, pending/accepted/rejected bundles, integration status, validation status, release candidate status, evidence links, and the next safe operator action. Console reads local artifacts and should not invent a separate state database for Patch Swarm. + +## Troubleshooting + +- If `validate --json` fails, inspect the failing gate and the evidence path in the JSON payload. +- If the 100-candidate fixture fails, inspect `validation-report.md`, `integration/conflict-report.md`, and rejected bundle receipts in the run directory. +- If status is empty, run the fixture E2E once and then rerun `cento parallel-delivery status --json`. +- If a path lease conflict appears, group the conflicting tasks sequentially or reduce the task split before dispatch. +- If live execution is blocked, first make the fixture path green and then inspect the explicit opt-in gate that refused the live action. + +## Extension Guide + +Add new Patch Swarm behavior by extending the existing `parallel-delivery` surface and associated helper module. A safe extension should add a fixture, schema or receipt updates, tests, docs, and evidence. Prefer additive command routes and stable JSON over rewriting the orchestration path. New lanes should declare owned paths, validation commands, evidence outputs, and failure handling before they are eligible for worker packets or integration. + +## Adoption Narrative + +Patch Swarm scales delivery without losing control because it separates exploration from mutation. Many candidate tasks can be planned, prompted, and evaluated, while write ownership, patch validation, integration, and release evidence remain deterministic. Staff engineers can review the artifacts asynchronously, leads can track progress through status and Console, and teams can adopt live workers only after the local fixture gate is routine. diff --git a/docs/platform-support.md b/docs/platform-support.md index 49845a8..b7acf18 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -4,98 +4,113 @@ This file is generated from `data/tools.json`. ## Summary -- macOS tools: 35 -- Linux tools: 44 -- both platforms: 32 -- Linux only: 12 +- macOS tools: 42 +- Linux tools: 52 +- both platforms: 39 +- Linux only: 13 - macOS only: 3 ## Tool Matrix | Tool | macOS | Linux | Description | |---|---:|---:|---| -| `agent-manager` | yes | yes | Control-plane scanner for Cento agents that detects stale, idle, stuck, errored, duplicated, manual, and low-value runs and writes actionable reports. | -| `agent-work` | yes | yes | Cento Taskstream CLI for assigning, splitting, dispatching, reviewing, archiving, and cutting over Cento agent tasks across the Mac/Linux cluster. | -| `agent-work-app` | yes | yes | Self-hosted Cento Console web app with Taskstream, Cluster, Consulting, and Docs sections, plus background process control, health checks, and migration import sync. | +| `agent-pool-kick` | yes | yes | Bounded worker-pool launcher that keeps builder, validator, small-task, and coordinator lanes moving without unbounded dispatch. | +| `agent-processes` | yes | yes | Mac-friendly Bubble Tea dashboard for cluster-wide managed and manual agent sessions, stale/risk indicators, and queue pressure. | +| `agent-work` | yes | yes | Taskstream-backed work tracker and first Cento Console section for assigning, splitting, dispatching, and reviewing Cento agent tasks across the Mac/Linux cluster. | +| `agent-work-hygiene` | yes | yes | Collect a point-in-time reconciliation report of agent run ledgers, tmux sessions, and Codex/Claude processes. | | `audio-quick-connect` | no | yes | Quickly connect a paired Bluetooth audio device by name or address with a short retry path and per-run logs. | | `batch-exec` | yes | yes | Run one shell command across multiple directories with dry-run and git-only support. | | `bluetooth-audio-doctor` | no | yes | Diagnose Bluetooth and Bluetooth-audio failures, generate detailed reports, and apply safe repair actions. | | `bridge` | yes | yes | Create a reverse SSH tunnel through the OCI VM so another machine can SSH back into this host through the VM relay. | +| `build` | yes | yes | Manifest-driven local build package primitive with owned path checks, Builder prompts, one-local-worker patch collection, dry-run patch integration, safe apply, and receipts. | | `burp` | no | yes | Download, set up, and control PortSwigger Burp Suite Community through cento wrappers. | | `cento-cli` | yes | yes | Unified cento facade for built-ins, terminal docs browsing, tool dispatch, and user-defined aliases. | +| `cento-mcp` | yes | yes | Local MCP stdio server that exposes safe Cento agent-work, story manifest, cluster, bridge, and context tools. | +| `claude-chores` | yes | yes | Discover, document, schedule, and launch bounded Claude Code maintenance chores for Cento without metered OpenAI API spend. | | `cluster` | yes | yes | Manage Cento node identity, cluster registry, colored status, remote execution, bridge healing, and read-only git drift checks. | +| `compute-policy` | yes | yes | Manage provider-share policy for Codex, Claude Code, and metered OpenAI API use, then sync Agent Work runtime weights. | | `crm` | yes | yes | Embedded cento CRM with questionnaire bootstrap, career-intake dossiers, local JSON persistence, and a self-hosted no-build SPA. | | `daily` | yes | yes | Bubble Tea execution cockpit for morning brief, midday recalibration, evening wrap-up, and local continuity. | | `dashboard` | no | yes | Run a localhost web dashboard with current state, recent cento activity, aliases, tools, and repo progress. | +| `demo-evidence` | yes | yes | Record short 10-30 second desktop demo videos as Factory, Codex worker, and validation evidence with receipts. | +| `discord` | no | yes | Update, rerun, and inspect Discord through a Cento-native Linux desktop control command. | | `display-layout-fix` | no | yes | Detect two connected monitors, stack them vertically, and refresh wallpaper plus polybar. | -| `factory` | yes | yes | Manifest-driven factory workflow that turns a high-level request into intake artifacts, queue state, integration gates, Autopilot dry-run control cycles, runtime adapter contracts, and static evidence hubs without default AI dispatch. | +| `factory` | yes | yes | Deterministic no-model Factory planning, dispatch dry-runs, patch collection, validation, and Safe Integrator workflows. | +| `foundry` | yes | yes | Create Cento-native business tools through Factory, Workset, parallel train promotion, storage policy, cost receipts, and demo evidence. | | `gather-context` | yes | yes | Gather AI-ready local and remote Cento context including platform support, repo state, command paths, MCP hints, and SSH connectivity. | | `i3reorg` | no | yes | Move numeric i3 workspaces to the bottom monitor, apply the preferred app map, and optionally place the Abao/Tokyo study YouTube window on top workspace L2 fullscreen. | | `incident` | yes | no | Bounded incident checks for Cento control-plane failures, with guarded SEV2 agent-work escalation for iPhone ce ingress failures. | | `install-linux` | no | yes | Install local Linux dependencies, wrappers, PATH block, and Zsh integration for cento. | | `install-macos` | yes | no | Install local macOS dependencies, wrappers, PATH block, and Zsh integration for cento. | | `kitty-theme-manager` | yes | yes | Manage Kitty themes with interactive selection, persistent logs, and tmux-aware refresh behavior. | -| `manifest-validate` | yes | yes | Deterministically validate story.json and validation.json pairs, including evidence paths, API specs, and allowlisted commands without AI. | | `mcp` | yes | yes | Manage repo-root MCP config, env templates, validation, and tool-call docs. | | `mobile` | yes | no | Native iOS/PWA mobile helper commands, including repeatable iOS e2e validation against the local mobile gateway. | +| `mozilla-vpn` | no | yes | Native Mozilla VPN control pane for the Industrial OS workspace, with status, UI launch, login, activate, and deactivate actions. | | `network-tui` | yes | yes | Cluster-focused Bubble Tea monitor for Cento nodes, connection state, activity state, tmux presence, VM mesh sockets, and companion-device reachability. | -| `no-model-validation-e2e` | yes | yes | Run generated story manifest, generated validation manifest, agent-work preflight, and Tier 0 validation in one zero-AI evidence loop. | | `notify` | yes | yes | Send cluster notifications to configured ntfy targets such as iPhone and Apple Watch mirrored alerts. | +| `object-storage` | yes | yes | Write dummy objects and mirror Cento run images to private Oracle Object Storage through the OCI CLI. | | `opencode` | yes | yes | Thin wrapper around opencode (Alisa-Novik fork of sst/opencode) — an open-source AI coding agent TUI. | +| `parallel-delivery` | yes | yes | Coordinate Hard ProReq fanout, Workset manifests, integrator/validator evidence, and demo receipts for the parallel AI delivery roadmap. | | `platform-report` | yes | yes | Report declared macOS and Linux support for registered cento tools and generate docs/platform-support.md. | | `preset` | no | yes | Apply managed Cento desktop presets such as the Industrial OS i3 theme and dashboard. | | `project-scaffold` | yes | yes | Scaffold a generic project with starter README, notes, scripts, data, and workspace folders. | +| `proreq-light` | yes | yes | Run the Hard ProReq artifact chain with the Pro planning request replaced by Codex Exec using a ChatGPT Pro simulation prompt. | | `quick-help` | no | yes | Rofi-based searchable help palette for cento built-ins, tools, and aliases. | | `quick-help-fzf` | yes | yes | Cross-platform fzf command palette for cento built-ins, tools, and aliases. | -| `rd` | no | yes | Terminate and relaunch Discord through the available desktop launcher. | +| `rd` | no | yes | Compatibility shortcut for `cento discord rerun`. | | `repo-snapshot` | yes | yes | Create a compact repo status report including tree, git status, diffstat, and recent commits. | +| `runtime` | yes | yes | Inspect and validate local builder runtime profiles used by Cento Build worker execution. | | `scan` | yes | yes | Scan cento for a topic and generate an archived HTML one-pager with explanation and snippets. | | `search-report` | yes | yes | Search a filesystem tree and write a Markdown report with matches and context. | -| `storage` | yes | yes | No-delete artifact catalog and retention planner for Cento run ledgers, manifests, patches, validation logs, screenshots, SQLite snapshots, prompts, and release evidence before high-fanout Factory work increases artifact volume. | -| `story-manifest` | yes | yes | Validate, draft, and render Cento agent-work story.json manifests. | -| `story-screenshot-runner` | yes | yes | Read screenshot requirements from story.json, capture desktop and mobile evidence with Playwright, and write deterministic metadata plus an index for Docs/Evidence and Validator lanes. | | `system-inventory` | yes | yes | Capture a Markdown baseline of host, shell, tooling, and environment state. | -| `temp` | no | yes | Short-lived operator wrappers for fragile one-off commands that should not be pasted as multiline shell. | +| `temp` | yes | yes | Short-lived operator wrappers for fragile one-off commands that should not be pasted as multiline shell. | | `tool-index` | yes | yes | Generate a Markdown tool index from the central registry. | | `tui` | yes | yes | Bubble Tea Telegram TUI with cached Go launcher, local config, and planned CRM hooks. | -| `validation-manifest` | yes | yes | Generate deterministic validation.json checks from story.json and enforce no-model coverage guardrails. | -| `validator-tier0` | yes | yes | Create validation packets and run deterministic Tier 0 checks with mandatory timing and AI budget stats. | +| `walk-autopilot` | yes | yes | Append-only follow-up coordinator for bounded Factory, spend-ledger, Hard ProReq, image fallback, agent-work hygiene, and worker-pool loops. | | `wallpaper-manager` | no | yes | Choose, preview, apply, and persist desktop wallpapers for i3 and feh. | +| `workset` | yes | yes | Minimal local N-worker runner for exclusive-path build tasks, structured API artifacts, dependency gates, budget caps, and sequential integration. | ## Available On Both -- `agent-manager` +- `agent-pool-kick` +- `agent-processes` - `agent-work` -- `agent-work-app` +- `agent-work-hygiene` - `batch-exec` - `bridge` +- `build` - `cento-cli` +- `cento-mcp` +- `claude-chores` - `cluster` +- `compute-policy` - `crm` - `daily` +- `demo-evidence` - `factory` +- `foundry` - `gather-context` - `kitty-theme-manager` -- `manifest-validate` - `mcp` - `network-tui` -- `no-model-validation-e2e` - `notify` +- `object-storage` - `opencode` +- `parallel-delivery` - `platform-report` - `project-scaffold` +- `proreq-light` - `quick-help-fzf` - `repo-snapshot` +- `runtime` - `scan` - `search-report` -- `storage` -- `story-manifest` -- `story-screenshot-runner` - `system-inventory` +- `temp` - `tool-index` - `tui` -- `validation-manifest` -- `validator-tier0` +- `walk-autopilot` +- `workset` ## Linux Only @@ -103,13 +118,14 @@ This file is generated from `data/tools.json`. - `bluetooth-audio-doctor` - `burp` - `dashboard` +- `discord` - `display-layout-fix` - `i3reorg` - `install-linux` +- `mozilla-vpn` - `preset` - `quick-help` - `rd` -- `temp` - `wallpaper-manager` ## macOS Only diff --git a/docs/schemas/cento.apply_receipt.v1.json b/docs/schemas/cento.apply_receipt.v1.json new file mode 100644 index 0000000..803b9f5 --- /dev/null +++ b/docs/schemas/cento.apply_receipt.v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cento Apply Receipt v1", + "type": "object", + "required": ["schema_version", "manifest_id", "bundle_id", "status", "patch_bundle", "integration_receipt", "checks", "applied", "rejections", "base_ref_manifest", "base_ref_current", "base_ref_match", "written_at"], + "properties": { + "schema_version": {"const": "cento.apply_receipt.v1"}, + "manifest_id": {"type": "string"}, + "bundle_id": {"type": "string"}, + "status": {"enum": ["applied", "rejected", "failed"]}, + "mode": {"type": "string"}, + "patch_bundle": {"type": "string"}, + "patch_path": {"type": ["string", "null"]}, + "integration_receipt": {"type": "string"}, + "touched_paths": {"type": "array", "items": {"type": "string"}}, + "changed_paths": {"type": "array", "items": {"type": "string"}}, + "checks": {"type": "array", "items": {"type": "object"}}, + "applied": {"type": "boolean"}, + "rejections": {"type": "array", "items": {"type": "string"}}, + "warnings": {"type": "array", "items": {"type": "string"}}, + "risk_overrides": {"type": "array", "items": {"type": "string"}}, + "dirty_owned_paths": {"type": "array", "items": {"type": "string"}}, + "dirty_unrelated_paths": {"type": "array", "items": {"type": "string"}}, + "base_ref_manifest": {"type": "string"}, + "base_ref_current": {"type": "string"}, + "base_ref_match": {"type": "boolean"}, + "validation_receipt": {"type": ["string", "null"]}, + "written_at": {"type": "string"} + } +} diff --git a/docs/schemas/cento.build.v1.json b/docs/schemas/cento.build.v1.json new file mode 100644 index 0000000..08583f2 --- /dev/null +++ b/docs/schemas/cento.build.v1.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cento Build Manifest v1", + "type": "object", + "required": ["schema_version", "id", "task", "mode", "source", "scope", "policies", "validation", "workers"], + "properties": { + "schema_version": {"const": "cento.build.v1"}, + "id": {"type": "string"}, + "task": { + "type": "object", + "required": ["title"], + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"} + } + }, + "mode": {"type": "string"}, + "mode_policy": {"type": "object"}, + "source": { + "type": "object", + "required": ["base_ref", "created_at"], + "properties": { + "base_ref": {"type": "string"}, + "created_at": {"type": "string"} + } + }, + "scope": { + "type": "object", + "required": ["routes", "read_paths", "write_paths", "protected_paths"], + "properties": { + "routes": {"type": "array", "items": {"type": "string"}}, + "read_paths": {"type": "array", "items": {"type": "string"}}, + "write_paths": {"type": "array", "items": {"type": "string"}}, + "protected_paths": {"type": "array", "items": {"type": "string"}} + } + }, + "policies": { + "type": "object", + "required": ["allow_unowned_changes", "allow_protected_changes", "dirty_repo_policy"], + "properties": { + "allow_unowned_changes": {"type": "boolean"}, + "allow_protected_changes": {"type": "boolean"}, + "allow_dirty_owned": {"type": "boolean"}, + "allow_deletes": {"type": "boolean"}, + "dirty_repo_policy": {"type": ["string", "object"]} + } + }, + "validation": { + "type": "object", + "required": ["tier"], + "properties": { + "tier": {"type": "string"}, + "commands": {"type": "array", "items": {"type": "object"}} + } + }, + "workers": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "write_paths"], + "properties": { + "id": {"type": "string"}, + "write_paths": {"type": "array", "items": {"type": "string"}} + } + } + }, + "acceptance": {"type": "array", "items": {"type": "string"}} + } +} diff --git a/docs/schemas/cento.integration_receipt.v1.json b/docs/schemas/cento.integration_receipt.v1.json new file mode 100644 index 0000000..4379de2 --- /dev/null +++ b/docs/schemas/cento.integration_receipt.v1.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cento Integration Receipt v1", + "type": "object", + "required": ["schema_version", "manifest_id", "status", "integration_mode", "checks", "applied", "dry_run", "rejections", "base_ref_manifest", "base_ref_current", "base_ref_match", "written_at"], + "properties": { + "schema_version": {"const": "cento.integration_receipt.v1"}, + "manifest_id": {"type": "string"}, + "status": {"enum": ["accepted", "rejected", "pending"]}, + "mode": {"type": "string"}, + "integration_mode": {"type": "string"}, + "patch_bundle": {"type": ["string", "null"]}, + "patch_bundle_id": {"type": ["string", "null"]}, + "patch_path": {"type": ["string", "null"]}, + "touched_paths": {"type": "array", "items": {"type": "string"}}, + "checks": {"type": "array", "items": {"type": "object"}}, + "applied": {"type": "boolean"}, + "dry_run": {"type": "boolean"}, + "rejections": {"type": "array", "items": {"type": "string"}}, + "warnings": {"type": "array", "items": {"type": "string"}}, + "risk_overrides": {"type": "array", "items": {"type": "string"}}, + "dirty_owned_paths": {"type": "array", "items": {"type": "string"}}, + "dirty_unrelated_paths": {"type": "array", "items": {"type": "string"}}, + "base_ref_manifest": {"type": "string"}, + "base_ref_worker": {"type": ["string", "null"]}, + "base_ref_current": {"type": "string"}, + "base_ref_match": {"type": "boolean"}, + "worktree_path": {"type": ["string", "null"]}, + "worktree_removed": {"type": ["boolean", "null"]}, + "validation_receipt": {"type": ["string", "null"]}, + "written_at": {"type": "string"} + } +} diff --git a/docs/schemas/cento.patch_bundle.v1.json b/docs/schemas/cento.patch_bundle.v1.json new file mode 100644 index 0000000..9652d92 --- /dev/null +++ b/docs/schemas/cento.patch_bundle.v1.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cento Patch Bundle v1", + "type": "object", + "required": ["schema_version", "manifest_id", "worker_id", "base_ref", "patch_file", "touched_paths", "owned_paths", "unowned_paths", "protected_paths_touched", "summary", "requires_integration"], + "properties": { + "schema_version": {"const": "cento.patch_bundle.v1"}, + "id": {"type": "string"}, + "manifest_id": {"type": "string"}, + "worker_id": {"type": "string"}, + "base_ref": {"type": "string"}, + "patch_file": {"type": "string"}, + "patch_sha256": {"type": "string"}, + "touched_paths": {"type": "array", "items": {"type": "string"}}, + "owned_paths": {"type": "array", "items": {"type": "string"}}, + "unowned_paths": {"type": "array", "items": {"type": "string"}}, + "protected_paths_touched": {"type": "array", "items": {"type": "string"}}, + "summary": {"type": "string"}, + "requires_integration": {"type": "boolean"} + } +} diff --git a/docs/schemas/cento.taskstream_evidence.v1.json b/docs/schemas/cento.taskstream_evidence.v1.json new file mode 100644 index 0000000..b95d25c --- /dev/null +++ b/docs/schemas/cento.taskstream_evidence.v1.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cento Taskstream Evidence v1", + "type": "object", + "required": ["schema_version", "type", "build_id", "mode", "manifest", "worker_artifacts", "patch_bundles", "status", "changed_files", "written_at"], + "properties": { + "schema_version": {"const": "cento.taskstream_evidence.v1"}, + "type": {"const": "cento_build_evidence"}, + "build_id": {"type": "string"}, + "task_id": {"type": ["string", "null"]}, + "mode": {"type": ["string", "null"]}, + "manifest": {"type": "string"}, + "worker_artifacts": {"type": "array", "items": {"type": "string"}}, + "patch_bundles": {"type": "array", "items": {"type": "string"}}, + "integration_receipt": {"type": ["string", "null"]}, + "validation_receipt": {"type": ["string", "null"]}, + "apply_receipt": {"type": ["string", "null"]}, + "events": {"type": ["string", "null"]}, + "changed_files": {"type": "array", "items": {"type": "string"}}, + "status": {"type": "string"}, + "risk_overrides": {"type": "array", "items": {"type": "string"}}, + "screenshots": {"type": "array", "items": {"type": "string"}}, + "written_at": {"type": "string"} + } +} diff --git a/docs/schemas/cento.validation_receipt.v1.json b/docs/schemas/cento.validation_receipt.v1.json new file mode 100644 index 0000000..a5e428e --- /dev/null +++ b/docs/schemas/cento.validation_receipt.v1.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cento Validation Receipt v1", + "type": "object", + "required": ["schema_version", "manifest_id", "tier", "status", "commands", "written_at"], + "properties": { + "schema_version": {"const": "cento.validation_receipt.v1"}, + "manifest_id": {"type": "string"}, + "tier": {"type": "string"}, + "status": {"enum": ["passed", "failed", "skipped"]}, + "commands": {"type": "array", "items": {"type": "object"}}, + "artifacts": {"type": "array"}, + "written_at": {"type": "string"} + } +} diff --git a/docs/schemas/cento.worker_artifact.v1.json b/docs/schemas/cento.worker_artifact.v1.json new file mode 100644 index 0000000..2a79563 --- /dev/null +++ b/docs/schemas/cento.worker_artifact.v1.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cento Worker Artifact v1", + "type": "object", + "required": ["schema_version", "manifest_id", "worker_id", "role", "status", "base_ref", "touched_paths"], + "properties": { + "schema_version": {"const": "cento.worker_artifact.v1"}, + "manifest_path": {"type": "string"}, + "manifest_id": {"type": "string"}, + "worker_id": {"type": "string"}, + "worker_type": {"type": "string"}, + "runtime": {"type": "string"}, + "fixture_case": {"type": ["string", "null"]}, + "role": {"type": "string"}, + "status": {"enum": ["completed", "rejected", "failed"]}, + "base_ref": {"type": "string"}, + "artifact_dir": {"type": "string"}, + "patch_file": {"type": ["string", "null"]}, + "patch_path": {"type": ["string", "null"]}, + "patch_bundle": {"type": ["string", "null"]}, + "handoff": {"type": "string"}, + "touched_paths": {"type": "array", "items": {"type": "string"}}, + "owned_paths": {"type": "array", "items": {"type": "string"}}, + "unowned_paths": {"type": "array", "items": {"type": "string"}}, + "protected_paths_touched": {"type": "array", "items": {"type": "string"}}, + "staged_paths": {"type": "array", "items": {"type": "string"}}, + "dirty_owned_paths": {"type": "array", "items": {"type": "string"}}, + "dirty_unrelated_paths": {"type": "array", "items": {"type": "string"}}, + "rejections": {"type": "array", "items": {"type": "string"}}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + "validation": {"type": ["object", "array"]}, + "risks": {"type": "array", "items": {"type": "string"}}, + "warnings": {"type": "array", "items": {"type": "string"}}, + "stdout_path": {"type": ["string", "null"]}, + "stderr_path": {"type": ["string", "null"]}, + "duration_ms": {"type": ["number", "integer", "null"]}, + "runtime_result": {"type": "object"}, + "launch_head": {"type": "string"}, + "worker_head": {"type": "string"}, + "started_at": {"type": "string"}, + "completed_at": {"type": "string"} + } +} diff --git a/docs/storage.md b/docs/storage.md index 2233d0a..ed83bc8 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -10,7 +10,9 @@ It answers: - which files are duplicate, bulky, private, or reproducible - which files are candidates for future compression, normalization, archive, or prune gates -The first slice is intentionally conservative. It catalogs and plans lifecycle actions, but it does not delete artifacts and does not upload anything to cloud storage. +The first slice is intentionally conservative. It catalogs and plans lifecycle actions, but it does not delete artifacts. + +OCI image mirroring now lives in the `object-storage` tool and has a readable guide at [`docs/oci-image-migration.html`](./oci-image-migration.html), with Markdown source at [`docs/oci-image-migration.md`](./oci-image-migration.md). That path is mirror-only: it uploads verified run images to private Standard Object Storage and leaves local originals unchanged. ## Commands @@ -54,7 +56,7 @@ Storage v1 has a no-delete posture. - Raw screenshots are only marked for normalization/compression until derivatives are verified. - SQLite DB/WAL files require controlled snapshots and integrity checks before movement. - Build intermediates can be reported as deletion candidates, but v1 will not prune them. -- Cloud upload is out of scope for v1. +- General cloud upload is out of scope for Storage v1. The supported cloud path is the image-specific `cento object-storage` mirror described in [`docs/oci-image-migration.html`](./oci-image-migration.html). SQLite snapshots use SQLite backup semantics and write metadata next to the snapshot. The command does not delete active WAL/SHM files: diff --git a/docs/temp-commands.md b/docs/temp-commands.md new file mode 100644 index 0000000..49c3fbe --- /dev/null +++ b/docs/temp-commands.md @@ -0,0 +1,40 @@ +# Cento Temp Clipboard + +`cento temp run` is intentionally a dumb clipboard bridge. + +The command is always exactly: + +```bash +cento temp run +``` + +It copies the fixed Markdown file configured in `scripts/cento_temp.sh`: + +```bash +COPY_FILE="/home/alice/projects/cento/workspace/runs/temp/cento-ultimate-ai-reference.md" +``` + +To change what `cento temp run` copies, edit only that `COPY_FILE` line. Do not +add IDs, flags, `show`, `list`, `add`, `remove`, cross-node routing, secret +prompts, generated temp command registries, or clipboard probing loops. + +Clipboard transport is handled by `pbcopy`. On Linux, fix the local `pbcopy` +shim if the terminal clipboard bridge breaks; do not expand `cento temp`. + +## Contract + +- Validate the command is exactly `run`. +- Validate `COPY_FILE` exists. +- Run `pbcopy < "$COPY_FILE"`. +- Print one copied line. + +## Validation + +```bash +bash -n scripts/cento_temp.sh +cento temp run +bash scripts/cento_temp.sh show +bash scripts/cento_temp.sh run extra +``` + +The last two commands should print `Usage: cento temp run` and exit non-zero. diff --git a/docs/tool-foundry.md b/docs/tool-foundry.md new file mode 100644 index 0000000..7d55939 --- /dev/null +++ b/docs/tool-foundry.md @@ -0,0 +1,115 @@ +# Cento Tool Foundry + +`cento foundry` creates Cento-native business tools through the existing delivery pipeline. + +Foundry is a facade over Factory, Workset, parallel train promotion, storage policy, cost receipts, and demo evidence. It does not replace those systems. The first v1 fixture is the career consulting **Client Intake Hub**. + +## Commands + +```bash +cento foundry create "client intake hub" --domain career-consulting --max-parallel 6 --budget-usd 10 --max-budget-usd 20 --json +cento foundry plan RUN_ID --json +cento foundry execute RUN_ID --runtime fixture --json +cento foundry execute RUN_ID --runtime api-openai --budget-usd 10 --max-budget-usd 20 --json +cento foundry promote RUN_ID --dry-run --json +cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --dry-run --json +cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --apply --json +cento foundry status RUN_ID --json +cento foundry validate RUN_ID --json +cento foundry e2e --fixture client-intake-hub --dry-run --json +cento foundry e2e --fixture client-intake-hub --dry-run --real-files --target-root templates/foundry/client-intake-hub --json +cento foundry e2e --fixture client-intake-hub --live --budget-usd 10 --max-budget-usd 20 --json +``` + +Use the dry-run fixture path for repeatable validation. Add `--real-files` when you want Foundry to produce a create-file materialization plan after the fixture train passes. Live `api-openai` execution requires both budget flags and v1 rejects hard caps above `$20`. + +## Artifacts + +Foundry runs write under: + +```text +workspace/runs/foundry// +``` + +The stable artifact set is: + +- `foundry-spec.json` +- `factory_handoff.json` +- `workset.json` +- `workset_check.json` +- `plan_receipt.json` +- `train_e2e_result.json` +- `execution_receipt.json` +- `cost_receipt.json` +- `storage-policy.json` +- `demo-evidence.json` +- `real_file_manifest.json` +- `materialization_plan.json` +- `materialization_receipt.json` +- `validation_summary.json` +- `summary.md` +- `tool/client-intake-hub/*` + +Train promotion writes under: + +```text +workspace/runs/parallel-delivery/train/foundry--train/ +workspace/runs/factory/parallel-train-foundry--train/ +``` + +## Behavior + +- `create` writes the Foundry spec, Client Intake Hub fixture bundle, private-by-default storage policy, demo evidence manifest, and initial cost receipt. +- `plan` runs deterministic Factory planning/materialization/queue evidence and writes a Workset manifest with six exclusive work slices. +- `execute` routes the Workset through `cento parallel-delivery train e2e`. +- `promote` can re-run the train-to-Factory promotion for the generated train run. +- `validate` requires the Foundry spec, Factory handoff, Workset check, execution receipt, train validation, promotion readiness, cost receipt, storage policy, and demo evidence. +- Fixture execution writes zero AI cost and is the required safe validation path. +- Live execution is explicit and capped. + +## Client Intake Hub Fixture + +The v1 fixture proves the pipeline for a career consulting tool without using real client data. + +The generated run bundle includes: + +- client profile schema +- command/API map routed through existing `cento crm` and `cento foundry` +- no-build HTML preview +- operator notes +- storage/leak policy +- validation plan + +Workset execution uses existing tracked fixture targets so isolated worker worktrees can produce patch bundles without mutating the main checkout. The generated product bundle and evidence remain run-scoped under `workspace/runs/foundry//`. + +## Real-File Materialization + +Real-file mode turns the run-scoped Client Intake Hub bundle into repo-ready files without asking isolated workers to edit paths they cannot see. + +The default target is: + +```text +templates/foundry/client-intake-hub/ +``` + +The materialized MVP writes: + +- `templates/foundry/client-intake-hub/client-intake-hub.html` +- `templates/foundry/client-intake-hub/client-profile.schema.json` +- `templates/foundry/client-intake-hub/command-api.json` +- `templates/foundry/client-intake-hub/storage-leak-policy.json` +- `templates/foundry/client-intake-hub/validation-plan.json` +- `templates/foundry/client-intake-hub/README.md` +- `docs/client-intake-hub.md` + +`materialize` defaults to planning unless `--apply` is passed. Apply writes only under `templates/foundry/...` plus the approved `docs/client-intake-hub.md` page. Existing files with identical content are skipped; changed existing files block instead of being overwritten. + +`cento crm serve` discovers the materialized Client Intake Hub and exposes a preview through the CRM Studio view. + +## Safety + +- Real resumes, LinkedIn exports, client notes, private job-search notes, secrets, and raw PII are blocked from cloud upload by default. +- OCI storage, when later used, must be private Standard tier with no public access. +- Foundry records cost receipts even when no AI calls occur. +- Foundry does not merge to main; promotion stops at Factory/Safe Integrator dry-run unless `--apply` is explicitly passed. +- Real-file materialization is local-only and does not upload to OCI. diff --git a/docs/tool-index.md b/docs/tool-index.md index 4c029d7..8346a10 100644 --- a/docs/tool-index.md +++ b/docs/tool-index.md @@ -24,6 +24,16 @@ - `cento install zsh` - `cento install tmux` - `cento run scan --query "mcp"` + - `cento build --help` + - `cento build init --task "Fixture docs page patch" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture` + - `cento build check tests/fixtures/cento_build/manifest.valid.json` + - `cento runtime check codex-fast` + - `cento workset check tests/fixtures/cento_workset/workset.valid.json` + - `cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke` + - `cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke` + - `cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke` + - `cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff` + - `cento build integrate tests/fixtures/cento_build/manifest.valid.json --bundle .cento/builds/build_fixture_docs_page_001/integration/patch_bundle.json --dry-run` - `cento platforms` - `cento platforms macos` - `cento platforms linux` @@ -99,7 +109,6 @@ - `cento crm intake init --person "Ada Lovelace"` - `cento crm intake add --person "Ada Lovelace" --kind resume --file ./resume.pdf` - `cento crm intake plan --person "Ada Lovelace"` - - `cento crm integration --provider redmine --person "Ada Lovelace" --start-workflow --dry-run` - `cento crm serve --open` - `cento crm show` - `cento crm docs` @@ -134,6 +143,19 @@ - `cento mcp docs` - `cento mcp paths` +## Cento MCP Server + +- `id`: `cento-mcp` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/cento_mcp_server.py` +- description: Local MCP stdio server that exposes safe Cento agent-work, story manifest, cluster, bridge, and context tools. +- commands: + - `python3 scripts/cento_mcp_server.py --list-tools` + - `python3 scripts/cento_mcp_server.py --call-tool cento_agent_work_list --arguments '{}'` + - `python3 scripts/cento_mcp_server.py --call-tool cento_context --arguments '{"remote":false}'` + - `cento mcp doctor` + ## Scan One Pager - `id`: `scan` @@ -147,64 +169,6 @@ - `cento scan --query "crm" --case-sensitive` - `cento scan --query "mcp" --port 47890` -## Validator Tier 0 - -- `id`: `validator-tier0` -- `lane`: `agent ops` -- `kind`: `python` -- `entrypoint`: `./scripts/validator_tier0.py` -- description: Create validation packets and run deterministic Tier 0 checks with mandatory timing and AI budget stats. -- commands: - - `cento validator-tier0 stories` - - `cento validator-tier0 run workspace/runs/validator-tier0/e2e/sample-pass.json` - - `cento validator-tier0 e2e` - - `cento validator-tier0 run workspace/runs/agent-work/no-model-validation-e2e/validation.json --run-dir workspace/runs/agent-work/no-model-validation-e2e/tier0` - -## Story Manifest - -- `id`: `story-manifest` -- `lane`: `agent ops` -- `kind`: `python` -- `entrypoint`: `./scripts/story_manifest.py` -- description: Validate, draft, and render Cento agent-work story.json manifests. -- commands: - - `cento story-manifest draft --title "Fix dashboard" --package app --expected-output workspace/runs/agent-work/drafts/fix-dashboard/evidence.md` - - `cento story-manifest validate workspace/runs/agent-work/no-model-validation-e2e/story.json` - - `cento story-manifest render-hub workspace/runs/agent-work/1000086/story.json` - -## Validation Manifest - -- `id`: `validation-manifest` -- `lane`: `agent ops` -- `kind`: `python` -- `entrypoint`: `./scripts/validation_manifest.py` -- description: Generate deterministic validation.json checks from story.json and enforce no-model coverage guardrails. -- commands: - - `cento validation-manifest draft workspace/runs/agent-work/no-model-validation-e2e/story.json --output workspace/runs/agent-work/no-model-validation-e2e/validation.json` - - `cento validation-manifest validate workspace/runs/agent-work/no-model-validation-e2e/validation.json` - -## No-model Validation E2E - -- `id`: `no-model-validation-e2e` -- `lane`: `agent ops` -- `kind`: `python` -- `entrypoint`: `./scripts/no_model_validation_e2e.py` -- description: Run generated story manifest, generated validation manifest, agent-work preflight, and Tier 0 validation in one zero-AI evidence loop. -- commands: - - `cento no-model-validation-e2e` - - `cento no-model-validation-e2e --run-dir workspace/runs/agent-work/no-model-validation-e2e` - -## Manifest Validate - -- `id`: `manifest-validate` -- `lane`: `agent ops` -- `kind`: `python` -- `entrypoint`: `./scripts/manifest_validate.py` -- description: Deterministically validate story.json and validation.json pairs, including evidence paths, API specs, and allowlisted commands without AI. -- commands: - - `cento manifest-validate --story workspace/runs/agent-work/1000088/story.json --validation workspace/runs/agent-work/1000088/validation.json --json --report workspace/runs/agent-work/1000088/validation-report.md` - - `python3 ./scripts/manifest_validate.py --story workspace/runs/agent-work/1000088/story.json --json` - ## Bluetooth Audio Doctor - `id`: `bluetooth-audio-doctor` @@ -260,6 +224,23 @@ - `cento preset industrial-os --dashboard-only --open` - `cento dashboard --theme industrial --open` +## Darth Lolipopus Pet Pane + +- `id`: `industrial-pet` +- `lane`: `desktop ops` +- `kind`: `shell` +- `entrypoint`: `./scripts/industrial_pet_tui.sh` +- description: Cute Sith Tamagotchi pane for Darth Lolipopus in the Industrial OS workspace. +- commands: + - `cento industrial-pet` + - `cento industrial-pet --once --width 98 --height 24` + - `cento industrial-pet --action nap` + - `cento industrial-pet --image assets/industrial-os/darth-lolipopus.png` + - `cento industrial-pet --portrait slot` + - `cento industrial-pet --reset` +- docs: + - [`docs/industrial-pet.md`](./industrial-pet.md) + ## Quick Help - `id`: `quick-help` @@ -370,6 +351,16 @@ - `./scripts/batch_exec.sh --root ~/projects --pattern '*' --command 'git status --short'` - `./scripts/batch_exec.sh --root ~/projects --pattern '*' --git-only --dry-run --command 'pwd'` +## Cento Temporary Commands + +- `id`: `temp` +- `lane`: `ops` +- `kind`: `shell` +- `entrypoint`: `./scripts/cento_temp.sh` +- description: One-command operator clipboard bridge that copies the fixed Markdown reference configured in scripts/cento_temp.sh through pbcopy. +- commands: + - `cento temp run` + ## Search Report - `id`: `search-report` @@ -381,15 +372,29 @@ - `./scripts/search_report.sh --query TODO --root ~/projects/cento` - `./scripts/search_report.sh --query bluetooth --root ~/projects` +## Discord Control + +- `id`: `discord` +- `lane`: `desktop ops` +- `kind`: `shell` +- `entrypoint`: `./scripts/restart_discord.sh` +- description: Update, rerun, and inspect Discord through a Cento-native Linux desktop control command. +- commands: + - `cento discord status` + - `cento discord update` + - `cento discord update --rerun` + - `cento discord rerun` + ## Restart Discord - `id`: `rd` - `lane`: `desktop ops` - `kind`: `shell` - `entrypoint`: `./scripts/restart_discord.sh` -- description: Terminate and relaunch Discord through the available desktop launcher. +- description: Compatibility shortcut for `cento discord rerun`. - commands: - `cento rd` + - `cento rd rerun` ## Tool Index Generator @@ -471,6 +476,7 @@ - `cento cluster status` - `cento cluster exec linux -- tmux ls` - `cento cluster exec macos -- cento gather-context --no-remote` + - `CENTO_IPHONE_URL=http://iphone-cento.local:47919 cento cluster exec iphone -- health` - `cento cluster sync` - `cento cluster heal` - `cento cluster heal linux` @@ -496,6 +502,24 @@ - `cento gather-context --json` - `cento gather-context --output workspace/runs/cento-context.md` +## Mozilla VPN Pane + +- `id`: `mozilla-vpn` +- `lane`: `desktop ops` +- `kind`: `shell` +- `entrypoint`: `./scripts/mozilla_vpn_tui.sh` +- description: Native Mozilla VPN control pane for the Industrial OS workspace, with status, UI launch, login, activate, and deactivate actions. +- commands: + - `cento mozilla-vpn` + - `cento mozilla-vpn --once` + - `cento mozilla-vpn status` + - `cento mozilla-vpn countries` + - `cento mozilla-vpn select COUNTRY` + - `cento mozilla-vpn ui` + - `cento mozilla-vpn login` + - `cento mozilla-vpn activate` + - `cento mozilla-vpn deactivate` + ## Cento Network Monitor - `id`: `network-tui` @@ -508,149 +532,139 @@ - `cento network-tui --no-remote` - `./scripts/network_tui.sh` -## Cento Taskstream CLI +## Agent Work Tracker - `id`: `agent-work` - `lane`: `agent ops` - `kind`: `python` - `entrypoint`: `./scripts/agent_work.py` -- description: Cento Taskstream CLI for assigning, splitting, dispatching, reviewing, archiving, and cutting over Cento agent tasks across the Mac/Linux cluster. +- description: Lifecycle and governance substrate for Taskstream-backed Cento work: story/validation manifests, prompt handoff, dispatch/run ledgers, and review across the Mac/Linux cluster. - commands: - `cento agent-work bootstrap` - - `cento agent-work create --title "Fix dashboard" --manifest workspace/runs/agent-work/drafts/fix-dashboard/story.json --node linux --agent codex` - - `CENTO_AGENT_WORK_BACKEND=dual cento agent-work create --title "Validate parity" --manifest workspace/runs/agent-work/drafts/validate-parity/story.json --node linux --agent codex` - - `cento agent-work preflight workspace/runs/agent-work/no-model-validation-e2e/story.json --validation-manifest workspace/runs/agent-work/no-model-validation-e2e/validation.json` + - `cento agent-work create --title "Fix dashboard" --node linux --agent codex` - `cento agent-work split --title "Improve mission control" --nodes linux,macos --task "Backend status" --task "Mac tile view"` - `cento agent-work list` - `cento agent-work show 123` - `cento agent-work claim 123 --node linux --agent codex` - `cento agent-work update 123 --status review --note "implemented and tested"` - - `CENTO_AGENT_WORK_BACKEND=dual cento agent-work update 123 --status validating --note "builder update path check"` - - `CENTO_AGENT_WORK_BACKEND=dual cento agent-work validate 123 --result pass --note "validation accepted" --evidence workspace/runs/agent-work/validation-report.md` - - `CENTO_AGENT_WORK_BACKEND=dual cento agent-work cutover-parity --all --run-dir workspace/runs/agent-work/cutover` - - `cento agent-work backup --run-dir workspace/runs/agent-work/cutover/e2e-check` - - `cento agent-work restore --bundle workspace/runs/agent-work/cutover/e2e-check/backup --verify` - - `cento agent-work archive --query "cutover"` - - `cento agent-work cutover-status` - - `cento agent-work cutover-freeze` - - `cento agent-work cutover-verify --run-dir workspace/runs/agent-work/cutover/e2e-check` - - `cento agent-work cutover-finalize --force` - - `cento agent-work review-drain --package mission-control --dry-run` - - `cento agent-work review-drain --package mission-control --apply` - `cento agent-work prompt 123` - `cento agent-work dispatch 123 --node linux --dry-run` - - `CENTO_AGENT_WORK_BACKEND=dual make agent-work-e2e` - - `CENTO_AGENT_WORK_BACKEND=dual make agent-work-dual-backend-stress` + - `cento agent-pool-kick --dry-run` + - `cento agent-pool-kick --max-launch 2 --runtime codex --model gpt-5.3-codex-spark` - `cento agent-work runs` - `cento agent-work runs --json --active` - `cento agent-work run-status RUN_ID --json` -## Agent Manager +## Compute Policy -- `id`: `agent-manager` +- `id`: `compute-policy` - `lane`: `agent ops` - `kind`: `python` -- `entrypoint`: `./scripts/agent_manager.py` -- description: Control-plane scanner for Cento agents that detects stale, idle, stuck, errored, duplicated, manual, and low-value runs and writes actionable reports. -- commands: - - `cento agent-manager scan` - - `cento agent-manager scan --json` - - `cento agent-manager report` - - `cento agent-manager recommend --limit 10` - - `cento agent-manager classify --issue-id 81` - - `cento agent-manager mark-stale RUN_ID --reason "stuck validator" --dry-run` - - `cento agent-manager mark-blocked 81 --reason "stuck validator" --evidence RUN_ID --dry-run` - - `cento agent-manager terminate-tmux cento-agent-81-095103 --reason "stuck validator" --dry-run` - - `make agent-manager ARGS="pool-stats --json"` +- `entrypoint`: `./scripts/compute_policy.py` +- description: Manage provider-share policy for Codex, Claude Code, and metered OpenAI API use, then sync Agent Work runtime weights. +- commands: + - `cento compute-policy show` + - `cento compute-policy show --json` + - `cento compute-policy preset codex-first --json` + - `cento compute-policy preset agent-preferred --json` + - `cento compute-policy set --codex 85 --claude 15 --openai-api 0 --json` + - `cento compute-policy apply --json` -## Cento Factory +## Agent Pool Kicker -- `id`: `factory` -- `lane`: `planning` +- `id`: `agent-pool-kick` +- `lane`: `agent ops` - `kind`: `python` -- `entrypoint`: `./scripts/factory.py` -- description: Manifest-driven factory workflow that turns a high-level request into intake artifacts, a validated factory-plan.json, story manifests, validation manifests, queue ledgers, owned-path leases, worktree metadata, prompt bundles, patch collection, integration dry-runs, isolated Safe Integrator branches, per-patch validation, rollback metadata, release candidates, release status, Autopilot dry-run control cycles, runtime adapter contracts, and static evidence hubs without default AI dispatch. +- `entrypoint`: `./scripts/agent_pool_kick.py` +- description: Dry-run-first bounded worker-pool planner and launcher for builder, validator, small-task, and coordinator lanes without unbounded dispatch. - commands: - - `cento factory intake "develop me a career consulting module" --dry-run --out workspace/runs/factory/factory-planning-e2e` - - `cento factory plan workspace/runs/factory/factory-planning-e2e --no-model` - - `cento factory materialize workspace/runs/factory/factory-planning-e2e` - - `cento factory create-issues workspace/runs/factory/factory-planning-e2e --dry-run` - - `cento factory preflight workspace/runs/factory/factory-planning-e2e --json` - - `cento factory queue workspace/runs/factory/factory-planning-e2e` - - `cento factory lease workspace/runs/factory/factory-planning-e2e --task crm-schema-extension --dry-run` - - `cento factory dispatch workspace/runs/factory/factory-planning-e2e --lane builder --max 4 --dry-run` - - `cento factory collect workspace/runs/factory/factory-planning-e2e` - - `cento factory validate workspace/runs/factory/factory-planning-e2e` - - `cento factory integrate workspace/runs/factory/factory-planning-e2e --dry-run` - - `cento factory integrate factory-integration-e2e --plan` - - `cento factory integrate factory-integration-e2e --prepare-branch --branch factory/factory-integration-e2e/integration` - - `cento factory integrate factory-integration-e2e --apply --validate-each --limit 3` - - `cento factory validate-integrated factory-integration-e2e` - - `cento factory release-candidate factory-integration-e2e` - - `cento factory sync-taskstream factory-integration-e2e --dry-run` - - `cento factory release workspace/runs/factory/factory-planning-e2e --json` - - `cento factory render-hub workspace/runs/factory/factory-planning-e2e` - - `cento factory status workspace/runs/factory/factory-planning-e2e` - - `cento factory autopilot factory-autopilot-runtime-e2e --dry-run --cycles 5` - - `cento factory autopilot-status factory-autopilot-runtime-e2e --json` - - `cento factory autopilot-render factory-autopilot-runtime-e2e` - - `cento factory runtime list --json` - - `cento factory runtime prepare factory-runtime-adapters-e2e --task factory-runtime-task-01 --runtime noop --dry-run` - - `cento factory runtime launch factory-runtime-adapters-e2e --task factory-runtime-task-01 --runtime noop --dry-run` - - `cento factory runtime status factory-runtime-adapters-e2e --task factory-runtime-task-01 --json` - - `cento factory runtime collect factory-runtime-adapters-e2e --task factory-runtime-task-01` - - `cento factory runtime cancel factory-runtime-adapters-e2e --task factory-runtime-task-01 --dry-run` - -## Cento Storage - -- `id`: `storage` -- `lane`: `platform ops` -- `kind`: `python` -- `entrypoint`: `./scripts/storage.py` -- description: No-delete artifact catalog and retention planner for Cento run ledgers, manifests, patches, validation logs, screenshots, SQLite snapshots, prompts, and release evidence before high-fanout Factory work increases artifact volume. -- commands: - - `cento storage scan --root workspace/runs --db workspace/storage/catalog.sqlite` - - `cento storage plan --dry-run` - - `cento storage query --largest --limit 20` - - `cento storage query --class screenshot_raw` - - `cento storage pressure --json` - - `cento storage normalize screenshots --dry-run` - - `cento storage compress logs --dry-run` - - `cento storage snapshot-db --path workspace/storage/catalog.sqlite --out workspace/storage/db-snapshots/catalog-snapshot.db` - - `cento storage restore-test --sample 10` - - `cento storage verify --all` - - `cento storage report --out workspace/storage/reports/storage-summary.md` - - `python3 scripts/storage_e2e.py --fixture mixed-artifacts --out workspace/runs/storage/cento-storage-v1` - -## Cento Console App - -- `id`: `agent-work-app` + - `cento agent-pool-kick --dry-run` + - `cento agent-pool-kick --max-launch 3 --dry-run` + - `cento agent-pool-kick --repair-missing-manifests --repair-apply --repair-lanes all --max-launch 0 --dry-run` + - `cento agent-pool-kick --package claude-chores --runtime claude-code --model claude-sonnet-4-6 --max-launch 2` + - `cento agent-pool-kick --max-launch 3 --model gpt-5.3-codex-spark` + - `cento agent-pool-kick --builder-target 2 --validator-target 2 --small-target 1 --coordinator-target 1` + - `python3 scripts/agent_pool_kick.py --dry-run` + +## Claude Code Chores + +- `id`: `claude-chores` - `lane`: `agent ops` - `kind`: `python` -- `entrypoint`: `./scripts/agent_work_app.py` -- description: Self-hosted Cento Console web app with Taskstream, Cluster, Consulting, and Docs sections, plus background process control, health checks, and migration import sync. -- commands: - - `cento agent-work-app start` - - `cento agent-work-app stop` - - `cento agent-work-app status` - - `cento agent-work-app import-redmine` - - `cento agent-work-app install-sync` - - `cento agent-work backup` - - `cento agent-work restore --bundle workspace/runs/agent-work/cutover/e2e-check/backup --verify` - - `cento agent-work archive --query "migration"` - - `cento agent-work cutover-status` - -## Story Screenshot Runner - -- `id`: `story-screenshot-runner` +- `entrypoint`: `./scripts/claude_chores.py` +- description: Discover, document, schedule, and launch bounded Claude Code maintenance chores for Cento without metered OpenAI API spend. +- commands: + - `cento claude-chores plan --scope broad-repo --json` + - `cento claude-chores run --scope broad-repo --chore-limit 2 --max-launch 2 --runtime claude-code --model claude-sonnet-4-6 --json` + - `cento claude-chores run --scope broad-repo --chore-limit 2 --max-launch 2 --dry-run --json` + - `cento claude-chores status --json` + - `cento claude-chores install-cron --interval-minutes 30 --json` + - `cento claude-chores uninstall-cron --json` + +## Walk Autopilot + +- `id`: `walk-autopilot` - `lane`: `agent ops` - `kind`: `python` -- `entrypoint`: `./scripts/story_screenshot_runner.py` -- description: Read screenshot requirements from story.json, capture desktop and mobile evidence with Playwright, and write deterministic metadata plus an index for Docs/Evidence and Validator lanes. +- `entrypoint`: `./scripts/walk_autopilot.py` +- description: Append-only follow-up coordinator for bounded Factory, spend-ledger, Hard ProReq, image fallback, agent-work hygiene, and worker-pool loops. +- commands: + - `cento walk-autopilot run --loops 12 --cadence-seconds 1200 --soft-cap-usd 12 --hard-cap-usd 20` + - `cento walk-autopilot start-tmux --loops 12 --cadence-seconds 1200 --hard-cap-usd 20 --allow-live-api --dashboard-total-spend-usd 0 --notify-target iphone` + - `cento walk-autopilot run --loops 1 --cadence-seconds 0` + - `cento walk-autopilot start-tmux --loops 12 --cadence-seconds 1200 --notify-target iphone` + - `cento walk-autopilot status` + - `cento walk-autopilot review-unblock run --mode report --json` + - `cento walk-autopilot review-unblock run --mode aggressive --json` + - `cento walk-autopilot review-unblock status --json` + - `cento walk-autopilot run --live-workers --review-unblock-mode aggressive` + - `cento walk-autopilot patch-swarm run --candidate-target 100 --max-parallel-agents 5 --json` + - `cento walk-autopilot patch-swarm status --json` + - `cento walk-autopilot routing run --json` + - `cento walk-autopilot routing status --json` + - `cento walk-autopilot routing install-cron --every-hours 4 --json` + - `cento walk-autopilot routing uninstall-cron --json` + - `cento walk-autopilot factory-scale start --duration-hours 6 --proreq-executions 30 --min-proreq-calls 100 --patch-swarm --json` + - `cento walk-autopilot factory-scale start-day --target-proreq-calls 3000 --max-proreq-calls 10000 --duration-hours 12 --batch-size 5 --json` + - `cento walk-autopilot factory-scale preflight --run-id RUN_ID --json` + - `cento walk-autopilot factory-scale advance --run-id RUN_ID --promotion-limit 25 --json` + - `cento walk-autopilot factory-scale promote --run-id RUN_ID --limit 100 --factory-run workspace/runs/factory/factory-scale-promotion-RUN_ID --json` + - `cento walk-autopilot factory-scale tick --run-id RUN_ID --batch-size 5 --json` + - `cento walk-autopilot factory-scale status --run-id RUN_ID --json` + - `cento walk-autopilot factory-scale install-cron --run-id RUN_ID --duration-hours 6 --json` + - `cento walk-autopilot factory-scale uninstall-cron --json` +- docs: + - [`docs/ai-review-unblock-autopilot.md`](./ai-review-unblock-autopilot.md) + - [`docs/ai-routing-nativeness-loop.md`](./ai-routing-nativeness-loop.md) + - [`docs/agent-work-live-dispatch-incident.md`](./agent-work-live-dispatch-incident.md) + - [`docs/factory-1000-patch-swarm-roadmap.md`](./factory-1000-patch-swarm-roadmap.md) + - [`docs/walk-autopilot-spend-cap-incident.md`](./walk-autopilot-spend-cap-incident.md) + +## Agent Work Hygiene + +- `id`: `agent-work-hygiene` +- `lane`: `agent ops` +- `kind`: `shell` +- `entrypoint`: `./scripts/agent_work_hygiene.sh` +- description: Collect a point-in-time reconciliation report of agent run ledgers, tmux sessions, and Codex/Claude processes. - commands: - - `cento story-screenshot-runner workspace/runs/agent-work/59/story.json` - - `cento story-screenshot-runner workspace/runs/agent-work/59/story.json --force` - - `./scripts/story_screenshot_runner.py workspace/runs/agent-work/59/story.json --force` + - `cento agent-work-hygiene` + - `cento agent-work-hygiene --issue 94` + - `cento agent-work-hygiene --out-dir workspace/runs/agent-work/reconciliation` + - `./scripts/agent_work_hygiene.sh` + +## Agent Processes Dashboard + +- `id`: `agent-processes` +- `lane`: `agent ops` +- `kind`: `shell` +- `entrypoint`: `./scripts/agent_processes_tui.sh` +- description: Read-only process and worker visibility for cluster-wide managed/manual agent sessions, stale/risk indicators, and queue pressure. +- commands: + - `cento agent-processes` + - `cento agent-processes --once` + - `./scripts/agent_processes_tui.sh` + - `./scripts/agent_processes_tui.sh --once` ## Cento Incident Response @@ -697,14 +711,226 @@ - `cento mobile watch-status` - `cento mobile docs` -## Cento Temporary Commands +## Demo Evidence Recorder -- `id`: `temp` -- `lane`: `ops` -- `kind`: `shell` -- `entrypoint`: `./scripts/cento_temp.sh` -- description: Short-lived operator wrappers for fragile one-off commands that should not be pasted as multiline shell. +- `id`: `demo-evidence` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/demo_evidence.py` +- description: Operator evidence utility for short 10-30 second desktop demo videos after real Factory, Codex worker, or validation flows exist. +- commands: + - `cento demo-evidence record --title "Factory UI walkthrough" --duration 15` + - `cento demo-evidence record --factory-run workspace/runs/factory/ --task --worker --duration 15 --notes "Shows accepted flow"` + - `cento demo-evidence record --duration 10 --recorder synthetic --out workspace/runs/demo-evidence/smoke --json` + - `cento demo-evidence record --duration 15 --dry-run --json` + - `cento demo-evidence verify workspace/runs/demo-evidence/` + - `cento demo-evidence status workspace/runs/demo-evidence/ --json` + +## Cento Factory + +- `id`: `factory` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/factory.py` +- description: Orchestration substrate for deterministic intake, planning, materialization, queueing, dry-run dispatch, patch collection, validation, integration, release candidates, and hubs. - commands: - - `cento run temp 1` - - `cento run temp 1 status` - - `cento run temp 1 rollback` + - `cento factory --help` + - `cento factory intake "develop me a career consulting module" --dry-run --out workspace/runs/factory/factory-planning-e2e` + - `cento factory plan workspace/runs/factory/factory-planning-e2e --no-model` + - `cento factory materialize workspace/runs/factory/factory-planning-e2e` + - `cento factory queue workspace/runs/factory/factory-planning-e2e` + - `cento factory dispatch workspace/runs/factory/factory-planning-e2e --lane builder --max 4 --dry-run` + - `cento factory collect workspace/runs/factory/factory-planning-e2e` + - `cento factory validate workspace/runs/factory/factory-planning-e2e` + - `cento factory integrate workspace/runs/factory/factory-planning-e2e --dry-run` + - `cento factory validate-fanout factory-integration-e2e --max-parallel 32 --json` + - `cento factory merge factory-integration-e2e --auto-merge-main --dry-run --json` + - `cento factory merge factory-integration-e2e --auto-merge-main --push --json` + - `cento factory status workspace/runs/factory/factory-planning-e2e` + +## Cento Build + +- `id`: `build` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/cento_build.py` +- description: Patch unit and safety substrate for manifest-owned paths, Builder prompts, patch bundles, dry-run integration, safe apply, and receipts. +- commands: + - `cento build --help` + - `cento build init --task "Fixture docs page patch" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture` + - `cento build check tests/fixtures/cento_build/manifest.valid.json` + - `cento build prompt tests/fixtures/cento_build/manifest.valid.json` + - `cento build artifact check tests/fixtures/cento_build/worker_artifact.valid.json` + - `cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180` + - `cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime-profile codex-fast --worktree` + - `cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime command --command "codex exec --prompt-file {prompt}" --allow-unsafe-command --worktree --timeout 180` + - `cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff` + - `cento build integrate .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --worktree --dry-run` + - `cento build apply .cento/builds//manifest.json --bundle .cento/builds//workers/builder_1/patch_bundle.json --from-receipt .cento/builds//integration_receipt.json` + - `cento build receipt .cento/builds/build_fixture_docs_page_001` + +## Cento Runtime Profiles + +- `id`: `runtime` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/cento_runtime.py` +- description: Inspect and validate local builder runtime profiles used by Cento Build worker execution. +- commands: + - `cento runtime list` + - `cento runtime list --json` + - `cento runtime check codex-fast` + - `cento runtime check codex-fast --json` + - `cento runtime check claude-code-fast --json` + - `cento runtime check python-fixture --require-executable` + +## Cento Workset + +- `id`: `workset` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/cento_workset.py` +- description: Parallel lease substrate for exclusive-path N-worker tasks, structured API artifacts, dependency gates, budget caps, and sequential integration. +- commands: + - `cento workset check tests/fixtures/cento_workset/workset.valid.json` + - `cento workset check tests/fixtures/cento_workset/workset.execute.api.json --runtime api-openai` + - `cento workset check tests/fixtures/cento_workset/workset.overlap.json` + - `cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke` + - `cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke` + - `cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke` + - `cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke` + - `cento workset materialize-artifact .cento/worksets//workers//artifact.json` + +## Oracle Object Storage + +- `id`: `object-storage` +- `lane`: `cloud ops` +- `kind`: `python` +- `entrypoint`: `./scripts/object_storage.py` +- description: Write dummy objects and mirror Cento run images to private Oracle Object Storage through the OCI CLI. +- commands: + - `cento object-storage status` + - `cento object-storage status --probe --json` + - `cento object-storage ensure-bucket --name cento-images-standard --region us-ashburn-1 --namespace NAMESPACE --json` + - `cento object-storage put-dummy --dry-run --json` + - `cento object-storage put-dummy --region us-ashburn-1 --bucket CENTO_BUCKET --namespace NAMESPACE --json` + - `cento object-storage e2e --json` + - `cento object-storage e2e --live --region us-ashburn-1 --bucket CENTO_BUCKET --namespace NAMESPACE --json` + - `cento object-storage plan-images --root workspace/runs --bucket cento-images-standard --namespace NAMESPACE --region us-ashburn-1 --json` + - `cento object-storage upload-images --manifest workspace/runs/object-storage//manifest.json --live --json` + - `cento object-storage verify-images --manifest workspace/runs/object-storage//upload-receipt.json --sample 10 --json` +- docs: + - [`docs/oci-image-migration.html`](./oci-image-migration.html) + - [`docs/oci-image-migration.md`](./oci-image-migration.md) + +## ProReq Light + +- `id`: `proreq-light` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/proreq_light.py` +- description: Run the Hard ProReq artifact chain with the Pro planning request replaced by Codex Exec using a ChatGPT Pro simulation prompt. +- commands: + - `cento proreq-light all` + - `cento proreq-light pro-request` + - `cento proreq-light codex-plan` + - `cento proreq-light backend-work` + - `cento proreq-light validation-plan` + - `cento proreq-light deliver --max-parallel 3 --runtime-profile codex-fast --json` +- docs: + - [`docs/dev-pipeline-run-contracts.md`](./dev-pipeline-run-contracts.md) + +## Cento Tool Foundry + +- `id`: `foundry` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/tool_foundry.py` +- description: Create Cento-native business tools through Factory, Workset, parallel train promotion, storage policy, cost receipts, and demo evidence. +- commands: + - `cento foundry create "client intake hub" --domain career-consulting --max-parallel 6 --budget-usd 10 --max-budget-usd 20 --json` + - `cento foundry plan RUN_ID --json` + - `cento foundry execute RUN_ID --runtime fixture --json` + - `cento foundry execute RUN_ID --runtime api-openai --budget-usd 10 --max-budget-usd 20 --json` + - `cento foundry promote RUN_ID --dry-run --json` + - `cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --dry-run --json` + - `cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --apply --json` + - `cento foundry status RUN_ID --json` + - `cento foundry validate RUN_ID --json` + - `cento foundry e2e --fixture client-intake-hub --dry-run --json` + - `cento foundry e2e --fixture client-intake-hub --dry-run --real-files --target-root templates/foundry/client-intake-hub --json` + - `cento foundry e2e --fixture client-intake-hub --live --budget-usd 10 --max-budget-usd 20 --json` +- docs: + - [`docs/tool-foundry.md`](./tool-foundry.md) + - [`docs/client-intake-hub.md`](./client-intake-hub.md) + +## Parallel AI Delivery + +- `id`: `parallel-delivery` +- `lane`: `agent ops` +- `kind`: `python` +- `entrypoint`: `./scripts/parallel_delivery.py` +- description: Patch Swarm and Parallel AI Delivery product facade over Factory orchestration, Build patch units, Workset leases, Agent Work lifecycle, and worker visibility. +- commands: + - `cento parallel-delivery plan --json` + - `cento parallel-delivery execute --sleep-seconds 1 --json` + - `cento parallel-delivery execute --live-pro --sleep-seconds 1 --json` + - `cento parallel-delivery demo --json` + - `cento parallel-delivery validate --json` + - `cento parallel-delivery status --json` + - `cento parallel-delivery train plan --workset tests/fixtures/cento_workset/workset.valid.json --max-parallel 10 --json` + - `cento parallel-delivery train run RUN_ID --simulate --json` + - `cento parallel-delivery train run RUN_ID --workset-execute --runtime fixture --validation smoke --allow-dirty-owned --json` + - `cento parallel-delivery train promote RUN_ID --dry-run --json` + - `cento parallel-delivery train e2e --workset tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --allow-dirty-owned --dry-run --json` + - `cento parallel-delivery train integrate RUN_ID --dry-run --json` + - `cento parallel-delivery train status RUN_ID --json` + - `cento parallel-delivery train validate RUN_ID --json` + - `cento parallel-delivery patch-swarm plan --candidate-target 100 --max-parallel-agents 5 --json` + - `cento parallel-delivery patch-swarm split --request-file REQUEST.md --candidate-target 20 --max-parallel-agents 5 --mode no-model --json` + - `cento parallel-delivery patch-swarm leases --run-dir workspace/runs/parallel-delivery/lease-fixture --run-id lease-fixture --fixture --json` + - `cento parallel-delivery patch-swarm validate-leases --run-dir workspace/runs/parallel-delivery/lease-fixture --json` + - `cento parallel-delivery patch-swarm prompts --run-dir workspace/runs/parallel-delivery/proreq-fixture --count 20 --lane all --chatgpt-pro --copy-to-temp --json` + - `cento parallel-delivery patch-swarm worker-packets --run-dir workspace/runs/parallel-delivery/codex-packets-fixture --run-id codex-packets-fixture --fixture --count 10 --json` + - `cento parallel-delivery patch-swarm dispatch --run-dir workspace/runs/parallel-delivery/worker-status-fixture --run-id worker-status-fixture --candidate-target 100 --max-parallel-agents 5 --dry-run --fixture --json` + - `cento parallel-delivery patch-swarm worker-status --run-dir workspace/runs/parallel-delivery/worker-status-fixture --json` + - `cento parallel-delivery status --run worker-status-fixture --run-root workspace/runs/parallel-delivery --json` + - `cento parallel-delivery patch-bundles validate --bundle workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles/bundle-safe-001.json --lease-manifest workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json --out workspace/runs/parallel-delivery/patch-bundle-fixture --base-commit HEAD --json` + - `cento parallel-delivery patch-bundles collect --run-id patch-bundle-fixture --bundles-dir workspace/runs/parallel-delivery/patch-bundle-fixture/input/bundles --lease-manifest workspace/runs/parallel-delivery/patch-bundle-fixture/input/leases.json --out workspace/runs/parallel-delivery/patch-bundle-fixture --base-commit HEAD --json` + - `cento parallel-delivery release-candidate create --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json --out workspace/runs/parallel-delivery/release-candidate-fixture/dry-run --mode dry-run --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo --base-commit HEAD --json` + - `cento parallel-delivery release-candidate create --integration-receipt workspace/runs/parallel-delivery/release-candidate-fixture/input/integration-receipt.accepted.json --out workspace/runs/parallel-delivery/release-candidate-fixture/apply --mode apply --target-repo workspace/runs/parallel-delivery/release-candidate-fixture/fixture-repo --target-worktree workspace/runs/parallel-delivery/release-candidate-fixture/integration-worktree --base-commit HEAD --final-validation-cmd "python -m pytest -q tests" --json` + - `cento parallel-delivery taskstream emit --split-plan workspace/runs/parallel-delivery/taskstream-fixture/input/split-plan.json --out workspace/runs/parallel-delivery/taskstream-fixture --transport manifest-only --run-preflight` + - `cento parallel-delivery taskstream preflight --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages --out workspace/runs/parallel-delivery/taskstream-fixture/preflight` + - `cento parallel-delivery taskstream apply --manifest-dir workspace/runs/parallel-delivery/taskstream-fixture/work-packages --out workspace/runs/parallel-delivery/taskstream-fixture/apply --transport agent-work --apply` + - `cento parallel-delivery patch-swarm execute RUN_ID --fixture --json` + - `cento parallel-delivery patch-swarm execute RUN_ID --live --budget-cap-usd 1 --max-budget-usd 1 --api-sandbox-candidates 1 --json` + - `cento parallel-delivery patch-swarm integrate RUN_ID --dry-run --json` + - `cento parallel-delivery patch-swarm integrate RUN_ID --apply --factory-run workspace/runs/factory/patch-swarm-RUN_ID --validate-each --json` + - `cento parallel-delivery patch-swarm validate RUN_ID --json` + - `cento parallel-delivery patch-swarm status RUN_ID --json` + - `cento parallel-delivery patch-swarm status --run-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 --write-html --json` + - `cento parallel-delivery patch-swarm e2e --candidate-target 30 --max-parallel-agents 3 --fixture --json` + - `cento parallel-delivery patch-swarm e2e --candidate-target 25 --max-parallel-agents 5 --fixture --run-id fixture-console-25 --output-dir workspace/runs/parallel-delivery/console-fixture/fixture-console-25 --json` + - `cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --run-root workspace/runs/parallel-delivery/e2e-fixture --json` + - `cento parallel-delivery self-improve run --json` + - `cento parallel-delivery self-improve e2e --candidate-target 30 --max-parallel-agents 3 --budget-cap-usd 1 --max-budget-usd 1 --apply --validate-each --auto-merge-gate --json` + - `cento parallel-delivery self-improve validate --json` + - `cento parallel-delivery self-improve status --json` + - `cento parallel-delivery self-improve install-cron --time 02:30` +- docs: + - [`docs/ai-self-improvement-autopilot.md`](./ai-self-improvement-autopilot.md) + - [`docs/ai-self-improvement-nightly.md`](./ai-self-improvement-nightly.md) + - [`docs/parallel-integration-train.md`](./parallel-integration-train.md) + - [`docs/parallel-ai-delivery-roadmap.md`](./parallel-ai-delivery-roadmap.md) + - [`docs/parallel-delivery/patch-swarm-artifacts.md`](./patch-swarm-artifacts.md) + - [`docs/parallel-delivery/patch-swarm-planner.md`](./patch-swarm-planner.md) + - [`docs/parallel-delivery/patch-swarm-leasing.md`](./patch-swarm-leasing.md) + - [`docs/parallel-delivery/patch-swarm-proreq-prompts.md`](./patch-swarm-proreq-prompts.md) + - [`docs/parallel-delivery/patch-swarm-codex-worker-packets.md`](./patch-swarm-codex-worker-packets.md) + - [`docs/parallel-delivery/patch-swarm-console.md`](./patch-swarm-console.md) + - [`docs/parallel-delivery/patch-bundle-validation.md`](./patch-bundle-validation.md) + - [`docs/parallel-delivery/release-candidate-safe-apply.md`](./release-candidate-safe-apply.md) + - [`docs/parallel-delivery/patch-swarm-validation-e2e.md`](./patch-swarm-validation-e2e.md) + - [`docs/parallel-delivery/patch-swarm-taskstream.md`](./patch-swarm-taskstream.md) + - [`docs/parallel-delivery/patch-swarm-worker-status.md`](./patch-swarm-worker-status.md) + - [`docs/patch-swarm.md`](./patch-swarm.md) diff --git a/docs/walk-autopilot-spend-cap-incident.md b/docs/walk-autopilot-spend-cap-incident.md new file mode 100644 index 0000000..e2d6650 --- /dev/null +++ b/docs/walk-autopilot-spend-cap-incident.md @@ -0,0 +1,143 @@ +# Walk Autopilot Spend Cap Incident + +Date: 2026-05-05 + +## Summary + +The Walk Autopilot run `walk-autopilot-20260505T152507Z` was stopped after the OpenAI usage dashboard showed spend above the intended `$20` total cap. + +Dashboard evidence from the operator showed: + +- May spend: `$45.82` +- Selected-range total spend: `$48.21` +- May 5 charges dominated by `gpt-5.4-pro-2026-03-05` input/output plus `gpt-image-1` image charges. + +The local run ledger had reported only `$0.33742`. That number was incomplete: it counted completed image receipts but did not include completed Pro response receipts for several `gpt-5.4-pro` calls. The dashboard is the source of truth for budget enforcement. + +## What Happened + +The run was started with a `$20` hard cap, and the operator intended that cap to mean total OpenAI dashboard/project spend. The coordinator interpreted it as local run-ledger spend. + +That distinction mattered because the run ledger was receipt based. It only counted records that Cento had successfully written locally. The dashboard still counted OpenAI work that had been accepted and billed even when Cento did not receive or persist a completed response receipt. + +The visible mismatch was: + +- Operator dashboard total: `$48.21` +- Operator May spend: `$45.82` +- Cento local run ledger before reconciliation: `$0.33742` + +The `$0.33742` report was therefore not a real cap state. It was only "locally receipted image spend so far." + +## Timeline + +| Time (UTC) | Event | +|---|---| +| 2026-05-05 15:25 | Walk Autopilot run `walk-autopilot-20260505T152507Z` started with live workers and live API lanes enabled. | +| 2026-05-05 16:05 to 17:15 | Multiple Hard ProReq runs wrote `gpt-5.4-pro` started records and request artifacts. | +| 2026-05-05 17:25 | Loop 7 status still showed local spend `$0.33742`, because Pro completions were not receipted locally. | +| 2026-05-05 17:38 | Operator reported dashboard spend above cap and clarified the `$20` cap was total dashboard/project spend. | +| 2026-05-05 17:38 | Coordinator was stopped, active tracked workers were checked, and a spend incident was opened. | +| 2026-05-05 17:49 | Run ledger was reconciled to the stricter selected-range dashboard total `$48.21`. | +| 2026-05-05 17:52 | Dashboard-total budget gates were validated to block live API starts before a tmux session or network call is created. | + +## Why The Ledger Was Wrong + +The old budget check trusted `workspace/runs/walk-autopilot//spend-ledger.jsonl` as the hard-cap source. That file was not complete enough for live Pro/image control. + +Known Pro runs had `pro_backend_request.json` plus local spend records with `status=started`, but no matching local completion or timeout receipt with final usage/cost: + +- `hard-proreq-task-hard-proreq-project-20260505T160522071163Z` +- `hard-proreq-task-hard-proreq-project-20260505T161522143806Z` +- `hard-proreq-task-hard-proreq-project-20260505T170546743595Z` +- `hard-proreq-task-hard-proreq-project-20260505T171546814172Z` + +This left the local ledger below reality while the OpenAI dashboard had the actual charges. + +## Impact + +- The intended `$20` OpenAI dashboard/project cap was exceeded. +- The local coordinator kept operating based on an undercounted run ledger. +- Live worker progress itself was not the main cost driver; the dashboard evidence points to explicit Pro/image lanes. +- The run artifacts are still useful for traceability, but the pre-incident local ledger cannot be used as a source of truth. + +## Immediate Response + +- Stopped the tmux coordinator for `walk-autopilot-20260505T152507Z`. +- Confirmed no tracked active Agent Work runs remained. +- Sent an iPhone incident notification. +- Reconciled the run spend ledger to the stricter selected-range dashboard total `$48.21`. +- Added a dashboard-total budget gate before any future live API Pro/image lane can launch. + +## Root Cause + +The original cap enforcement used the local append-only run ledger as the hard-cap source. That was insufficient because Pro calls can incur dashboard spend even when local coordination times out or fails before writing a completed response receipt with usage. + +The intended cap was total OpenAI dashboard/project spend, not run-local delta. + +## New Guardrail + +Any future Walk Autopilot run with `--allow-live-api` must include `--dashboard-total-spend-usd` or `CENTO_OPENAI_DASHBOARD_TOTAL_SPEND_USD`. + +If the supplied dashboard total is already greater than or equal to `--hard-cap-usd`, the coordinator exits before starting. Hard ProReq Pro/image dispatch also checks `CENTO_REQUIRE_DASHBOARD_TOTAL_BUDGET=1` and blocks network calls when the dashboard total is missing or over cap. + +Example blocked restart while total is over cap: + +```bash +./scripts/cento.sh walk-autopilot start-tmux \ + --loops 12 \ + --cadence-seconds 1200 \ + --hard-cap-usd 20 \ + --allow-live-api \ + --dashboard-total-spend-usd 48.21 +``` + +Expected behavior: + +- Exit code `2` +- No tmux session is created +- No Pro/image network call is attempted +- Error payload explains the dashboard total and hard cap comparison + +## Verification + +The fix was validated with: + +```bash +python3 -m pytest tests/test_walk_autopilot.py tests/test_dev_pipeline_delivery.py -q +make check +python3 -m json.tool data/tools.json +./scripts/cento.sh walk-autopilot start-tmux --allow-live-api --hard-cap-usd 20 --dashboard-total-spend-usd 48.21 +``` + +The over-cap `start-tmux` command exited before creating a session. The focused tests cover: + +- response-ID dedupe in the spend ledger +- dashboard total baseline accounting +- live API gate failure when dashboard total is missing +- live API gate failure when dashboard total exceeds the hard cap +- Hard ProReq Pro dispatch blocked before network calls +- Hard ProReq image dispatch blocked before network calls + +## Operating Rule Going Forward + +For any metered OpenAI API work, budget gates must use the OpenAI dashboard/project total when the operator states a total cap. Local ledgers are useful for attribution and reconciliation, but they cannot be the sole stop condition for Pro/image lanes. + +Allowed without a dashboard spend snapshot: + +- local validation +- no-model checks +- dry-run planning +- Codex/Claude agent lanes that do not call metered OpenAI API + +Blocked without a dashboard spend snapshot: + +- `walk-autopilot --allow-live-api` +- Hard ProReq `gpt-5.4-pro` dispatch +- OpenAI image generation +- `parallel-delivery self-improve` when it enables Pro/image lanes + +## Incident Artifacts + +- `workspace/runs/walk-autopilot/walk-autopilot-20260505T152507Z/incidents/spend-cap-dashboard-20260505T173800Z/incident.md` +- `workspace/runs/walk-autopilot/walk-autopilot-20260505T152507Z/handoff.md` +- `workspace/runs/walk-autopilot/walk-autopilot-20260505T152507Z/spend-ledger.jsonl` diff --git a/scripts/agent_pool_kick.py b/scripts/agent_pool_kick.py index 8578da0..ae68b08 100644 --- a/scripts/agent_pool_kick.py +++ b/scripts/agent_pool_kick.py @@ -4,6 +4,7 @@ import argparse import json import os +import re import shlex import subprocess import sys @@ -15,10 +16,13 @@ ROOT = Path(__file__).resolve().parent.parent STATE_DIR = Path.home() / ".local" / "state" / "cento" DEFAULT_TARGETS = {"builder": 4, "validator": 3, "small": 3, "coordinator": 1} +DEFAULT_AGENT_RUNTIME = os.environ.get("CENTO_AGENT_RUNTIME", "auto") DEFAULT_CODEX_MODEL = os.environ.get("CENTO_POOL_CODEX_MODEL", "gpt-5.4-mini") +DEFAULT_CLAUDE_MODEL = os.environ.get("CENTO_POOL_CLAUDE_MODEL", "claude-sonnet-4-6") ACTIVE_STATUSES = {"planned", "launching", "running"} ENDED_STATUSES = {"dry_run", "succeeded", "failed", "blocked", "stale", "exited_unknown"} LANES = ("validator", "small", "builder", "coordinator") +DEFAULT_REPAIR_LANES = ("validator", "small", "builder", "coordinator") SMALL_TOKENS = ("screenshot", "evidence", "strict review", "template", "fixture", "docs", "process", "heartbeat", "stale", "cron", "pool") VALIDATION_MODES = ("no-model", "cheap-model", "strong-model") DEFAULT_CHEAP_VALIDATOR_MODEL = DEFAULT_CODEX_MODEL @@ -27,6 +31,17 @@ HIGH_RISK_VALUES = {"high", "critical"} +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def rel_path(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + def run_json(command: list[str], timeout: int = 25) -> dict[str, Any]: result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=timeout, check=False) if result.returncode != 0: @@ -108,6 +123,211 @@ def story_manifest_path(issue_id: int) -> Path: return ROOT / "workspace" / "runs" / "agent-work" / str(issue_id) / "story.json" +def validation_manifest_path(issue_id: int) -> Path: + return ROOT / "workspace" / "runs" / "agent-work" / str(issue_id) / "validation.json" + + +def slugify(value: str, fallback: str = "agent-work") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return slug[:64] or fallback + + +def issue_text(issue: dict[str, Any]) -> str: + parts = [ + str(issue.get("subject") or ""), + str(issue.get("description") or ""), + str(issue.get("package") or ""), + ] + return "\n\n".join(part.strip() for part in parts if part and part.strip()) + + +def repair_role_for_lane(issue: dict[str, Any], lane: str) -> str: + if lane == "validator": + return "validator" + if lane == "coordinator": + return "coordinator" + if lane in {"small", "builder"}: + return "builder" + role = str(issue.get("role") or "builder").strip() or "builder" + return role if role in {"builder", "validator", "coordinator", "docs-evidence"} else "builder" + + +def parse_repair_lanes(value: str) -> tuple[str, ...]: + raw = [item.strip() for item in str(value or "").split(",") if item.strip()] + if not raw or raw == ["all"]: + return DEFAULT_REPAIR_LANES + lanes: list[str] = [] + for item in raw: + if item == "all": + lanes.extend(lane for lane in DEFAULT_REPAIR_LANES if lane not in lanes) + continue + if item not in LANES: + raise ValueError(f"unknown repair lane: {item}") + if item not in lanes: + lanes.append(item) + return tuple(lanes) + + +def build_repaired_story_manifest(issue: dict[str, Any], *, lane: str) -> dict[str, Any]: + issue_id = int(issue.get("id") or 0) + subject = str(issue.get("subject") or f"Agent work {issue_id}").strip() + package = str(issue.get("package") or "agent-ops").strip() or "agent-ops" + role = repair_role_for_lane(issue, lane) + run_dir = f"workspace/runs/agent-work/{issue_id}" + validation_manifest = f"{run_dir}/validation.json" + output_path = f"{run_dir}/worker-handoff.md" + validation_mode = "cheap-model" if role == "validator" else "no-model" + return { + "schema_version": "1.0", + "issue": {"id": issue_id, "title": subject, "package": package}, + "lane": { + "owner": "agent-pool-kick", + "node": str(issue.get("node") or "linux"), + "agent": str(issue.get("agent") or ""), + "role": role, + }, + "paths": {"run_dir": run_dir}, + "scope": { + "goal": issue_text(issue) or subject, + "acceptance": [ + "Worker produces a handoff that lists delivered changes, validation, evidence, and residual risk.", + "Worker preserves unrelated dirty work and keeps edits scoped to the interpreted issue request.", + ], + }, + "expected_outputs": [ + { + "path": output_path, + "description": "Worker handoff summarizing implementation, validation, evidence, and residual risk.", + "owner": "agent-pool-kick", + "required": True, + } + ], + "validation": { + "manifest": validation_manifest, + "mode": validation_mode, + "no_model_eligible": validation_mode == "no-model", + "risk": "medium", + "escalation_triggers": ["missing_manifest", "failed_deterministic_command", "ambiguity"], + "commands": [ + f"python3 -m json.tool {run_dir}/story.json", + f"test -s {output_path}", + ], + }, + "deliverables": { + "manifest": f"{run_dir}/deliverables.json", + "hub": f"{run_dir}/start-here.html", + }, + "review_gate": { + "required_sections": ["Delivered", "Validation", "Evidence", "Residual risk"], + "residual_risk_required": True, + }, + "metadata": { + "drafted_at": now_iso(), + "source": "agent-pool-kick-manifest-repair", + "repair_lane": lane, + "repair_policy": "Minimal canonical story generated to restore dispatch preflight eligibility; worker must produce the actual evidence handoff.", + "slug": slugify(subject), + }, + } + + +def build_repaired_validation_manifest(story: dict[str, Any], story_path: Path) -> dict[str, Any]: + validation = story.get("validation") if isinstance(story.get("validation"), dict) else {} + commands = validation.get("commands") if isinstance(validation.get("commands"), list) else [] + checks = [ + { + "name": f"command-{index}", + "type": "command", + "command": str(command), + "cwd": ".", + "timeout_seconds": 30, + "expect_exit": 0, + "required": True, + } + for index, command in enumerate(commands, start=1) + if str(command or "").strip() + ] + return { + "schema": "cento.validation-manifest.v1", + "task": str(story.get("issue", {}).get("title") or story_path.stem), + "story_manifest": rel_path(story_path), + "claim": str(story.get("scope", {}).get("goal") or ""), + "risk": "medium", + "decision_requested": "approve", + "checks": checks, + "manual_review": [], + "coverage": { + "deterministic_checks": len(checks), + "manual_review_items": 0, + "automation_coverage_percent": 100.0 if checks else 0.0, + }, + "stats_policy": { + "ai_calls_used": 0, + "estimated_ai_cost": 0, + "requires_total_duration_ms": True, + "requires_per_check_duration_ms": True, + }, + "created_at": now_iso(), + "source": "agent-pool-kick-manifest-repair", + } + + +def repair_missing_manifests( + all_issues: list[dict[str, Any]], + *, + apply: bool, + limit: int, + lanes: tuple[str, ...] = DEFAULT_REPAIR_LANES, + issue_ids: set[int] | None = None, +) -> list[dict[str, Any]]: + repairs: list[dict[str, Any]] = [] + seen: set[int] = set() + requested_issue_ids = issue_ids or set() + for issue in all_issues: + if len(repairs) >= limit: + break + issue_id = int(issue.get("id") or 0) + if issue_id <= 0 or issue_id in seen: + continue + forced = issue_id in requested_issue_ids + matching_lane = next( + ( + lane + for lane in lanes + if issue_is_candidate(issue, lane) or (forced and issue_matches_lane(issue, lane)) + ), + "", + ) + if not matching_lane: + continue + story_path = story_manifest_path(issue_id) + validation_path = validation_manifest_path(issue_id) + story_missing = not story_path.exists() + validation_missing = not validation_path.exists() + if not story_missing and not validation_missing: + continue + story = build_repaired_story_manifest(issue, lane=matching_lane) + validation = build_repaired_validation_manifest(story, story_path) + record = { + "issue": issue_id, + "lane": matching_lane, + "subject": issue.get("subject"), + "story_manifest": rel_path(story_path), + "validation_manifest": rel_path(validation_path), + "story_missing": story_missing, + "validation_missing": validation_missing, + "applied": bool(apply), + "forced": forced, + } + if apply: + story_path.parent.mkdir(parents=True, exist_ok=True) + story_path.write_text(json.dumps(story, indent=2, sort_keys=True) + "\n", encoding="utf-8") + validation_path.write_text(json.dumps(validation, indent=2, sort_keys=True) + "\n", encoding="utf-8") + repairs.append(record) + seen.add(issue_id) + return repairs + + def load_story_manifest(issue_id: int) -> tuple[dict[str, Any] | None, Path, str]: path = story_manifest_path(issue_id) if not path.exists(): @@ -469,25 +689,45 @@ def build_reason_summary( } -def dispatch(issue: dict[str, Any], lane: str, *, model_override: str | None = None) -> dict[str, Any]: +def dispatch_runtime(model_override: str | None = None, runtime_override: str | None = None) -> str: + runtime = runtime_override or DEFAULT_AGENT_RUNTIME + if runtime == "auto" and model_override and str(model_override).startswith("gpt-"): + return "codex" + return runtime + + +def dispatch_model(runtime: str, model_override: str | None = None) -> str: + if runtime == "claude-code": + return model_override or os.environ.get("CENTO_POOL_CLAUDE_MODEL") or DEFAULT_CLAUDE_MODEL + if runtime == "codex": + return model_override or DEFAULT_CHEAP_VALIDATOR_MODEL + if runtime == "auto": + return model_override or "" + return model_override or DEFAULT_CHEAP_VALIDATOR_MODEL + + +def dispatch( + issue: dict[str, Any], + lane: str, + *, + runtime_override: str | None = None, + model_override: str | None = None, +) -> dict[str, Any]: issue_id = str(issue["id"]) if lane == "small": role = "builder" agent = "small-worker-pool" - runtime = "codex" elif lane == "validator": role = "validator" agent = "validator-pool" - runtime = "codex" elif lane == "coordinator": role = "coordinator" agent = "coordinator-pool" - runtime = "codex" else: role = "builder" agent = "builder-pool" - runtime = "codex" - model = model_override or DEFAULT_CHEAP_VALIDATOR_MODEL + runtime = dispatch_runtime(model_override, runtime_override) + model = dispatch_model(runtime, model_override) command = [ "./scripts/cento.sh", "agent-work", @@ -502,7 +742,7 @@ def dispatch(issue: dict[str, Any], lane: str, *, model_override: str | None = N "--runtime", runtime, ] - if runtime == "codex" and model: + if model: command.extend(["--model", model]) result = run(command, timeout=90) return { @@ -525,11 +765,36 @@ def main() -> int: parser.add_argument("--small-target", type=int, default=DEFAULT_TARGETS["small"]) parser.add_argument("--coordinator-target", type=int, default=DEFAULT_TARGETS["coordinator"]) parser.add_argument("--max-launch", type=int, default=8) + parser.add_argument("--runtime", default="", help="Runtime id for launched workers, such as auto, codex, or claude-code.") + parser.add_argument("--model", default="", help="Model override passed to agent-work dispatch for AI runtime lanes.") + parser.add_argument("--package", default="", help="Only consider Taskstream issues from this package.") parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--repair-missing-manifests", action="store_true", help="Plan minimal canonical story/validation manifests for otherwise eligible live-lane tasks.") + parser.add_argument("--repair-apply", action="store_true", help="Write manifest repairs before dispatch planning. Use with --dry-run to repair without launching workers.") + parser.add_argument("--repair-limit", type=int, default=3) + parser.add_argument("--repair-lanes", default="all", help="Comma-separated lanes to repair: validator,small,builder,coordinator, or all.") + parser.add_argument("--repair-issue", type=int, action="append", default=[], help="Force manifest repair for this issue id if it matches a selected lane, even after a preflight failure changed its status.") args = parser.parse_args() runs = active_runs() all_issues = issues() + package_filter = str(args.package or "").strip() + if package_filter: + all_issues = [issue for issue in all_issues if str(issue.get("package") or "") == package_filter] + manifest_repairs: list[dict[str, Any]] = [] + if args.repair_missing_manifests: + try: + repair_lanes = parse_repair_lanes(args.repair_lanes) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + manifest_repairs = repair_missing_manifests( + all_issues, + apply=args.repair_apply, + limit=max(0, args.repair_limit), + lanes=repair_lanes, + issue_ids=set(args.repair_issue or []), + ) blocked = active_issue_ids(runs) counts = active_pool_counts(runs) targets = { @@ -553,6 +818,15 @@ def main() -> int: } if lane == "validator": validation_route = planned_validation_route(issue) + runtime_override = str(args.runtime or "") or None + if str(args.model or ""): + model_override = str(args.model) + elif runtime_override == "claude-code": + model_override = None + else: + model_override = DEFAULT_CHEAP_VALIDATOR_MODEL if validation_route["mode"] == "cheap-model" else DEFAULT_STRONG_VALIDATOR_MODEL + planned_runtime = dispatch_runtime(model_override if validation_route["mode"] != "no-model" else None, runtime_override) + planned_model = dispatch_model(planned_runtime, model_override) if validation_route["mode"] != "no-model" else "" record.update( { "validation_mode": validation_route["mode"], @@ -561,16 +835,25 @@ def main() -> int: "planned_validation_reason": validation_route["reason"], "story_manifest": validation_route["story_manifest"], "validation_manifest": validation_route["validation_manifest"], + "planned_runtime": "local" if validation_route["mode"] == "no-model" else planned_runtime, + "planned_model": planned_model, } ) if not args.dry_run: if validation_route["mode"] == "no-model": record.update(launch_local_validation(issue, validation_route, dry_run=False)) else: - model_override = DEFAULT_CHEAP_VALIDATOR_MODEL if validation_route["mode"] == "cheap-model" else DEFAULT_STRONG_VALIDATOR_MODEL - record.update(dispatch(issue, lane, model_override=model_override)) + record.update(dispatch(issue, lane, runtime_override=runtime_override, model_override=model_override)) elif not args.dry_run: - record.update(dispatch(issue, lane, model_override=None)) + record.update(dispatch(issue, lane, runtime_override=str(args.runtime or "") or None, model_override=str(args.model or "") or None)) + else: + planned_runtime = dispatch_runtime(None, str(args.runtime or "") or None) + record.update( + { + "planned_runtime": planned_runtime, + "planned_model": dispatch_model(planned_runtime, str(args.model or "") or None) or "weighted-runtime-default", + } + ) launched.append(record) reserved.add(int(issue["id"])) if len(launched) >= args.max_launch: @@ -581,6 +864,8 @@ def main() -> int: "dry_run": args.dry_run, "active_counts": counts, "targets": targets, + "package_filter": package_filter, + "manifest_repairs": manifest_repairs, "launched": launched, "successful_launches": successful_launches, "failed_launches": [item for item in launched if item.get("returncode", 0) not in (0, None)], diff --git a/scripts/agent_processes_tui.go b/scripts/agent_processes_tui.go new file mode 100644 index 0000000..4fcd2ad --- /dev/null +++ b/scripts/agent_processes_tui.go @@ -0,0 +1,681 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// ── data types ────────────────────────────────────────────────────────────── + +type runsResponse struct { + Runs []runRecord `json:"runs"` +} + +type runRecord struct { + RunID string `json:"run_id"` + IssueID int `json:"issue_id"` + IssueSubject string `json:"issue_subject"` + Status string `json:"status"` + Health string `json:"health"` + Role string `json:"role"` + Agent string `json:"agent"` + Runtime string `json:"runtime"` + Node string `json:"node"` + Elapsed string `json:"elapsed"` + UpdatedAt string `json:"updated_at"` + PIDAlive bool `json:"pid_alive"` + TmuxAlive bool `json:"tmux_alive"` + Package string `json:"package"` + Command string `json:"command"` + CWD string `json:"cwd"` +} + +type issuesResponse struct { + Issues []issueRecord `json:"issues"` +} + +type issueRecord struct { + ID int `json:"id"` + Subject string `json:"subject"` + Status string `json:"status"` + Node string `json:"node"` + Agent string `json:"agent"` + Role string `json:"role"` + Package string `json:"package"` +} + +type managerScan struct { + Summary managerSummary `json:"summary"` +} + +type managerSummary struct { + Live int `json:"live"` + ManagedLive int `json:"managed_live"` + Manual int `json:"manual"` + Stale int `json:"stale"` + ActionableStale int `json:"actionable_stale"` + RiskCount int `json:"risk_count"` + Warning int `json:"warning"` + ByRole map[string]int `json:"by_role"` + ByRuntime map[string]int `json:"by_runtime"` +} + +type processRow struct { + RunID string + IssueID int + Subject string + Status string + Health string + Role string + Runtime string + Node string + Elapsed string + Alive bool + Package string + Command string + CWD string +} + +type queueRow struct { + IssueID int + Subject string + Status string + Node string + Agent string + Role string +} + +type processData struct { + Runs []processRow + Queue []queueRow + Counts map[string]int + Scan *managerSummary + UpdatedAt time.Time + Err error +} + +// ── tea messages ───────────────────────────────────────────────────────────── + +type dataLoadedMsg struct{ data processData } +type tickMsg time.Time + +// ── model ──────────────────────────────────────────────────────────────────── + +type model struct { + root string + width int + height int + interval time.Duration + loading bool + selected int + data processData +} + +// ── styles ─────────────────────────────────────────────────────────────────── + +var ( + orange = lipgloss.Color("#FF4B00") + amber = lipgloss.Color("#FF9A3D") + green = lipgloss.Color("#62E886") + red = lipgloss.Color("#FF5E4A") + blue = lipgloss.Color("#8DB9C7") + purple = lipgloss.Color("#B68CFF") + text = lipgloss.Color("#D8D0C4") + muted = lipgloss.Color("#8B746F") + panelStyle = lipgloss.NewStyle().Foreground(text).Padding(1, 1) + titleStyle = lipgloss.NewStyle().Foreground(orange).Bold(true) + mutedStyle = lipgloss.NewStyle().Foreground(muted) + ruleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#8A4A45")) + hdrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFF4DC")) + idStyle = lipgloss.NewStyle().Foreground(orange) + nameStyle = lipgloss.NewStyle().Foreground(text) +) + +// ── tea interface ───────────────────────────────────────────────────────────── + +func (m model) Init() tea.Cmd { + return tea.Batch(loadDataCmd(m.root), tickCmd(m.interval)) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "r": + m.loading = true + return m, loadDataCmd(m.root) + case "j", "down": + if m.selected < len(m.data.Runs)-1 { + m.selected++ + } + case "k", "up": + if m.selected > 0 { + m.selected-- + } + } + case tickMsg: + if m.loading { + return m, tickCmd(m.interval) + } + m.loading = true + return m, tea.Batch(loadDataCmd(m.root), tickCmd(m.interval)) + case dataLoadedMsg: + m.loading = false + m.data = msg.data + if m.selected >= len(m.data.Runs) { + m.selected = max(0, len(m.data.Runs)-1) + } + } + return m, nil +} + +func (m model) View() tea.View { + w := m.width + if w <= 0 { + w = 100 + } + w = clamp(w-2, 50, 160) + body := m.renderBody(w - 2) + view := tea.NewView(panelStyle.Width(w).Render(body)) + view.AltScreen = true + return view +} + +// ── render ─────────────────────────────────────────────────────────────────── + +func (m model) renderBody(width int) string { + rule := ruleStyle.Render(strings.Repeat("─", max(8, width))) + parts := []string{ + mutedStyle.Render(time.Now().Format("15:04:05") + " agent-processes"), + titleStyle.Render("> AGENT PROCESSES DASHBOARD"), + rule, + m.renderSummary(width), + "", + } + if m.data.Err != nil { + parts = append(parts, nameStyle.Foreground(red).Render(m.data.Err.Error())) + } else { + if len(m.data.Runs) == 0 { + parts = append(parts, mutedStyle.Render("No active agent runs.")) + } else { + parts = append(parts, hdrStyle.Render("ACTIVE RUNS")) + parts = append(parts, m.renderRuns(width)) + } + if len(m.data.Queue) > 0 { + parts = append(parts, "", rule, hdrStyle.Render("QUEUE")) + parts = append(parts, m.renderQueue(width)) + } + if m.data.Scan != nil { + parts = append(parts, "", rule) + parts = append(parts, m.renderScan(width)) + } + } + hint := "r refresh · q quit · auto " + m.interval.String() + if m.loading { + hint = "refreshing… " + hint + } + parts = append(parts, "", mutedStyle.Render(hint)) + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func (m model) renderSummary(width int) string { + d := m.data + live := len(d.Runs) + queued := 0 + running := 0 + for _, q := range d.Queue { + switch strings.ToLower(q.Status) { + case "queued": + queued++ + case "running": + running++ + } + } + items := []string{ + hdrStyle.Render("LIVE"), nameStyle.Render(fmt.Sprintf("%d", live)), + hdrStyle.Render("RUNNING"), nameStyle.Render(fmt.Sprintf("%d", running)), + hdrStyle.Render("QUEUED"), nameStyle.Render(fmt.Sprintf("%d", queued)), + } + if d.Scan != nil { + items = append(items, + hdrStyle.Render("STALE"), nameStyle.Render(fmt.Sprintf("%d", d.Scan.Stale)), + hdrStyle.Render("RISK"), nameStyle.Render(fmt.Sprintf("%d", d.Scan.RiskCount)), + ) + } + line := strings.Join(items, " ") + return clipLine(line, width) +} + +func (m model) renderRuns(width int) string { + roleW := 10 + rtW := 12 + nodeW := 6 + elW := 7 + healthW := 7 + gutters := 10 + doingW := max(10, width-roleW-rtW-nodeW-elW-healthW-gutters) + maxShow := len(m.data.Runs) + if m.height > 0 { + maxShow = min(maxShow, max(3, m.height/3)) + } else { + maxShow = min(maxShow, 8) + } + lines := make([]string, 0, maxShow+1) + lines = append(lines, strings.Join([]string{ + cell(mutedStyle.Render("HEALTH"), healthW, false), + cell(mutedStyle.Render("ROLE"), roleW, false), + cell(mutedStyle.Render("RUNTIME"), rtW, false), + cell(mutedStyle.Render("NODE"), nodeW, false), + cell(mutedStyle.Render("ELAPSED"), elW, true), + mutedStyle.Render("DOING"), + }, " ")) + for i, row := range m.data.Runs[:maxShow] { + health := row.Health + if health == "" { + health = row.Status + } + healthText := statusStyle(health).Render(clip(strings.ToUpper(health), healthW)) + prefix := " " + if i == m.selected { + prefix = ">" + } + doing := prefix + clip(processDoing(row), doingW-1) + lines = append(lines, strings.Join([]string{ + cell(healthText, healthW, false), + cell(nameStyle.Render(clip(row.Role, roleW)), roleW, false), + cell(mutedStyle.Render(clip(row.Runtime, rtW)), rtW, false), + cell(mutedStyle.Render(clip(row.Node, nodeW)), nodeW, false), + cell(mutedStyle.Render(clip(row.Elapsed, elW)), elW, true), + idStyle.Render(doing), + }, " ")) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m model) renderQueue(width int) string { + statusW := 10 + roleW := 10 + nodeW := 6 + gutters := 6 + subjW := max(10, width-statusW-roleW-nodeW-gutters) + maxShow := min(len(m.data.Queue), 6) + lines := make([]string, 0, maxShow+1) + lines = append(lines, strings.Join([]string{ + cell(mutedStyle.Render("STATUS"), statusW, false), + cell(mutedStyle.Render("ROLE"), roleW, false), + cell(mutedStyle.Render("NODE"), nodeW, false), + mutedStyle.Render("SUBJECT"), + }, " ")) + for _, row := range m.data.Queue[:maxShow] { + statusText := statusStyle(strings.ToLower(row.Status)).Render(clip(row.Status, statusW)) + lines = append(lines, strings.Join([]string{ + cell(statusText, statusW, false), + cell(nameStyle.Render(clip(row.Role, roleW)), roleW, false), + cell(mutedStyle.Render(clip(row.Node, nodeW)), nodeW, false), + nameStyle.Render(clip(issueLabel(row.IssueID, row.Subject), subjW)), + }, " ")) + } + if len(m.data.Queue) > maxShow { + lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … and %d more", len(m.data.Queue)-maxShow))) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m model) renderScan(width int) string { + s := m.data.Scan + parts := []string{hdrStyle.Render("MANAGER SCAN")} + roleItems := []string{} + for role, count := range s.ByRole { + roleItems = append(roleItems, fmt.Sprintf("%s:%d", role, count)) + } + sort.Strings(roleItems) + rtItems := []string{} + for rt, count := range s.ByRuntime { + rtItems = append(rtItems, fmt.Sprintf("%s:%d", rt, count)) + } + sort.Strings(rtItems) + parts = append(parts, + nameStyle.Render(fmt.Sprintf("live %d managed %d manual %d stale %d actionable-stale %d risk %d warn %d", + s.Live, s.ManagedLive, s.Manual, s.Stale, s.ActionableStale, s.RiskCount, s.Warning)), + ) + if len(roleItems) > 0 { + parts = append(parts, mutedStyle.Render("by-role: "+strings.Join(roleItems, " "))) + } + if len(rtItems) > 0 { + parts = append(parts, mutedStyle.Render("by-runtime: "+strings.Join(rtItems, " "))) + } + _ = width + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +// ── data loading ───────────────────────────────────────────────────────────── + +func loadDataCmd(root string) tea.Cmd { + return func() tea.Msg { + return dataLoadedMsg{data: loadData(root)} + } +} + +func tickCmd(interval time.Duration) tea.Cmd { + return tea.Tick(interval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func loadData(root string) processData { + runs, err := loadRuns(root) + if err != nil { + return processData{Err: err, UpdatedAt: time.Now(), Counts: map[string]int{}} + } + queue, _ := loadQueue(root) + runs = enrichProcessRows(runs, queue) + scan, _ := loadManagerScan(root) + counts := map[string]int{} + for _, r := range runs { + counts[strings.ToLower(r.Health)]++ + } + return processData{ + Runs: runs, + Queue: queue, + Counts: counts, + Scan: scan, + UpdatedAt: time.Now(), + } +} + +func runPython(root string, args ...string) ([]byte, error) { + cmd := exec.Command("python3", args...) + cmd.Dir = root + return cmd.Output() +} + +func loadRuns(root string) ([]processRow, error) { + raw, err := runPython(root, "scripts/agent_work.py", "runs", "--json", "--active") + if err != nil { + return nil, fmt.Errorf("agent_work.py runs: %w", err) + } + var resp runsResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("parse runs: %w", err) + } + rows := make([]processRow, 0, len(resp.Runs)) + for _, r := range resp.Runs { + rows = append(rows, processRow{ + RunID: r.RunID, + IssueID: r.IssueID, + Subject: r.IssueSubject, + Status: r.Status, + Health: r.Health, + Role: r.Role, + Runtime: r.Runtime, + Node: r.Node, + Elapsed: r.Elapsed, + Alive: r.PIDAlive || r.TmuxAlive, + Package: r.Package, + Command: r.Command, + CWD: r.CWD, + }) + } + return rows, nil +} + +func loadQueue(root string) ([]queueRow, error) { + raw, err := runPython(root, "scripts/agent_work.py", "list", "--json") + if err != nil { + return nil, err + } + var resp issuesResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, err + } + rows := make([]queueRow, 0, len(resp.Issues)) + for _, iss := range resp.Issues { + if strings.ToLower(iss.Status) == "done" { + continue + } + rows = append(rows, queueRow{ + IssueID: iss.ID, + Subject: iss.Subject, + Status: iss.Status, + Node: iss.Node, + Agent: iss.Agent, + Role: iss.Role, + }) + } + return rows, nil +} + +func loadManagerScan(root string) (*managerSummary, error) { + scanScript := filepath.Join(root, "scripts", "agent_manager.py") + if _, err := os.Stat(scanScript); err != nil { + return nil, nil + } + raw, err := runPython(root, "scripts/agent_manager.py", "scan", "--json") + if err != nil { + return nil, nil + } + var scan managerScan + if err := json.Unmarshal(raw, &scan); err != nil { + return nil, nil + } + return &scan.Summary, nil +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func initRoot() string { + if root := os.Getenv("CENTO_ROOT_DIR"); root != "" { + return root + } + if cwd, err := os.Getwd(); err == nil { + if _, err := os.Stat(filepath.Join(cwd, "data", "tools.json")); err == nil { + return cwd + } + } + return "." +} + +func issueLabel(id int, subject string) string { + if id > 0 { + return fmt.Sprintf("#%d %s", id, subject) + } + return subject +} + +func processDoing(row processRow) string { + subject := strings.TrimSpace(row.Subject) + if subject != "" { + return issueLabel(row.IssueID, subject) + } + if row.Package != "" && row.IssueID > 0 { + return fmt.Sprintf("#%d package %s", row.IssueID, row.Package) + } + command := compactCommand(row.Command) + cwd := compactPath(row.CWD) + if command != "" && cwd != "" { + return command + " @ " + cwd + } + if command != "" { + return command + } + if cwd != "" { + return "shell @ " + cwd + } + if row.RunID != "" { + return row.RunID + } + return "unknown" +} + +func enrichProcessRows(runs []processRow, queue []queueRow) []processRow { + if len(runs) == 0 || len(queue) == 0 { + return runs + } + byID := make(map[int]queueRow, len(queue)) + for _, issue := range queue { + byID[issue.IssueID] = issue + } + for index := range runs { + if runs[index].IssueID == 0 || strings.TrimSpace(runs[index].Subject) != "" { + continue + } + if issue, ok := byID[runs[index].IssueID]; ok { + runs[index].Subject = issue.Subject + } + } + return runs +} + +func compactCommand(command string) string { + fields := strings.Fields(command) + if len(fields) == 0 { + return "" + } + for index, field := range fields { + base := filepath.Base(field) + if base == "codex" || base == "claude" { + end := min(len(fields), index+2) + parts := []string{base} + for _, part := range fields[index+1 : end] { + parts = append(parts, filepath.Base(part)) + } + return strings.Join(parts, " ") + } + } + return filepath.Base(fields[0]) +} + +func compactPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + if path == home { + return "~" + } + prefix := home + string(os.PathSeparator) + if strings.HasPrefix(path, prefix) { + return "~/" + strings.TrimPrefix(path, prefix) + } + } + return path +} + +func statusStyle(status string) lipgloss.Style { + base := lipgloss.NewStyle().Bold(true) + switch status { + case "running", "active": + return base.Foreground(blue) + case "queued": + return base.Foreground(purple) + case "done", "succeeded", "success": + return base.Foreground(green) + case "failed", "error": + return base.Foreground(red) + case "stale", "warning": + return base.Foreground(amber) + default: + return base.Foreground(text) + } +} + +func clip(value string, width int) string { + if lipgloss.Width(value) <= width { + return value + } + runes := []rune(value) + if width <= 1 || len(runes) <= width { + return value + } + return string(runes[:width-1]) + "…" +} + +func clipLine(value string, width int) string { + if lipgloss.Width(value) <= width { + return value + } + return clip(value, width) +} + +func cell(value string, width int, right bool) string { + current := lipgloss.Width(value) + if current >= width { + return value + } + padding := strings.Repeat(" ", width-current) + if right { + return padding + value + } + return value + padding +} + +func clamp(value, low, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func isTTY() bool { + info, err := os.Stdout.Stat() + return err == nil && (info.Mode()&os.ModeCharDevice) != 0 +} + +func main() { + root := initRoot() + fs := flag.NewFlagSet("agent-processes-tui", flag.ExitOnError) + once := fs.Bool("once", false, "render once and exit (for CI / non-interactive)") + interval := fs.Duration("interval", 5*time.Second, "refresh interval") + if err := fs.Parse(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + m := model{root: root, interval: *interval, data: loadData(root), width: 100} + if *once || !isTTY() { + fmt.Println(m.renderBody(98)) + if m.data.Err != nil { + os.Exit(1) + } + return + } + + program := tea.NewProgram(m) + if _, err := program.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/agent_processes_tui.sh b/scripts/agent_processes_tui.sh new file mode 100755 index 0000000..8db5001 --- /dev/null +++ b/scripts/agent_processes_tui.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +source "$SCRIPT_DIR/lib/common.sh" + +ROOT_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +CACHE_DIR="$ROOT_DIR/workspace/tmp/bin" +BINARY="$CACHE_DIR/cento-agent-processes-tui" +SOURCE_FILE="$ROOT_DIR/scripts/agent_processes_tui.go" +GO_MOD="$ROOT_DIR/go.mod" +GO_SUM="$ROOT_DIR/go.sum" + +cento_require_cmd go +cento_ensure_dir "$CACHE_DIR" +export CENTO_ROOT_DIR="$ROOT_DIR" +unset NO_COLOR +export CLICOLOR=1 +export CLICOLOR_FORCE=1 +export COLORTERM="${COLORTERM:-truecolor}" + +if [[ ! -x "$BINARY" || "$SOURCE_FILE" -nt "$BINARY" || "$GO_MOD" -nt "$BINARY" || ( -f "$GO_SUM" && "$GO_SUM" -nt "$BINARY" ) ]]; then + (cd -- "$ROOT_DIR" && go build -o "$BINARY" ./scripts/agent_processes_tui.go) +fi + +exec "$BINARY" "$@" diff --git a/scripts/agent_work.py b/scripts/agent_work.py index 4e55379..d9df763 100755 --- a/scripts/agent_work.py +++ b/scripts/agent_work.py @@ -1149,6 +1149,15 @@ def command_runtime(command: str) -> str: return "" +def process_cwd(pid: int) -> str: + if pid <= 0: + return "" + try: + return os.readlink(f"/proc/{pid}/cwd") + except OSError: + return "" + + def read_agent_processes() -> list[dict[str, Any]]: try: proc = subprocess.run(["ps", "-eo", "pid=,ppid=,stat=,etime=,command="], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5, check=False) @@ -1175,6 +1184,7 @@ def read_agent_processes() -> list[dict[str, Any]]: "elapsed": elapsed, "command": command, "runtime": runtime, + "cwd": process_cwd(int(pid)), } ) return processes @@ -1220,7 +1230,7 @@ def untracked_interactive_runs(records: list[dict[str, Any]]) -> list[dict[str, "exit_code": None, "prompt_path": "", "log_path": "", - "cwd": "", + "cwd": proc.get("cwd", ""), "git_head": "", "ledger_path": "", "source": "ps", @@ -4523,7 +4533,7 @@ def run_validation_check(check: dict[str, Any], context: dict[str, str]) -> dict name = str(check.get("name") or kind) if kind == "command": result = run_command_check(check, context) - elif kind == "file": + elif kind in ("file", "file_exists"): result = run_file_check(check, context) elif kind == "url": result = run_url_check(check, context) diff --git a/scripts/agent_work_app.py b/scripts/agent_work_app.py index 615edf0..ee4a8ff 100644 --- a/scripts/agent_work_app.py +++ b/scripts/agent_work_app.py @@ -2,9 +2,12 @@ from __future__ import annotations import argparse +import glob +import hashlib import json import mimetypes import os +import re import signal import socket import sqlite3 @@ -15,7 +18,8 @@ import webbrowser import shlex import shutil -from datetime import datetime, timezone +from copy import deepcopy +from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any @@ -76,6 +80,35 @@ LOG_FILE = STATE_DIR / "agent-work-app.log" SYNC_LOG_FILE = STATE_DIR / "agent-work-app-sync.log" SYNC_LOCK_FILE = STATE_DIR / "agent-work-app-sync.lock" +DEV_PIPELINE_STUDIO_ROOT = ROOT_DIR / "workspace" / "runs" / "dev-pipeline-studio" / "docs-pages" / "latest" +DEV_PIPELINE_EXECUTION_LOCK = threading.Lock() +DEV_PIPELINE_EXECUTION_MIN_STEP_SECONDS = float(os.environ.get("CENTO_PIPELINE_EXECUTION_MIN_STEP_SECONDS", "3.0")) +HARD_PROREQ_PROJECT_ID = "hard-proreq-project" +HARD_PROREQ_TEMPLATE_ID = "hard-proreq-task" +PROREQ_LIGHT_PROJECT_ID = "proreq-light-project" +PROREQ_LIGHT_TEMPLATE_ID = "proreq-light-task" +MULTIPIPELINE_PROJECT_ID = "multipipeline-proreq-project" +MULTIPIPELINE_TEMPLATE_ID = "multipipeline-proreq-chain" +PARALLEL_PIPELINE_PROJECT_ID = "parallel-pipeline-project" +PARALLEL_PIPELINE_TEMPLATE_ID = "parallel-pipeline" +PATCH_SWARM_PROJECT_ID = "patch-swarm-project" +PATCH_SWARM_TEMPLATE_ID = "patch-swarm" +PARALLEL_PIPELINE_FIXTURE_TARGET_PATHS = [ + "docs/agent-run-ledger.md", + "docs/agent-work-coordinator-lane.md", + "docs/agent-work-deliverables-hub.md", + "docs/agent-work-docs-evidence-lane.md", + "docs/agent-work-runtimes.md", + "docs/agent-work-screenshot-runner.md", + "docs/agent-work-story-manifest.md", + "docs/agent-work-validator-lane.md", + "docs/cento-build.md", + "docs/cento-workset.md", + "standards/README.md", + "standards/mcp.md", +] +DEFAULT_DEV_PIPELINE_PROJECT_ID = HARD_PROREQ_PROJECT_ID +DEFAULT_DEV_PIPELINE_TEMPLATE_ID = HARD_PROREQ_TEMPLATE_ID HEALTH_PATH = "/health" SYNC_CRON_BEGIN = "# >>> cento agent-work-app sync >>>" SYNC_CRON_END = "# <<< cento agent-work-app sync <<<" @@ -83,6 +116,43 @@ SYNC_TIMEOUT_ENV = "CENTO_AGENT_WORK_APP_SYNC_TIMEOUT_SECONDS" +def load_local_cento_secrets() -> None: + config_root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + secrets_path = Path(os.environ.get("CENTO_SECRETS_ENV", config_root / "cento" / "secrets.env")) + try: + lines = secrets_path.read_text(encoding="utf-8").splitlines() + except OSError: + return + allowed = { + "OPENAI_API_KEY", + "CENTO_OPENAI_PLANNER_MODEL", + "CENTO_OPENAI_WORKER_MODEL", + "CENTO_OPENAI_REVIEWER_MODEL", + "CENTO_OPENAI_PRO_MODEL", + "CENTO_OPENAI_IMAGE_MODEL", + } + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("export "): + stripped = stripped[7:].lstrip() + if "=" not in stripped: + continue + key, raw_value = stripped.split("=", 1) + key = key.strip() + if key not in allowed or os.environ.get(key): + continue + try: + parsed = shlex.split(raw_value, posix=True) + except ValueError: + parsed = [raw_value.strip().strip("\"'")] + os.environ[key] = parsed[0] if parsed else "" + + +load_local_cento_secrets() + + class AgentWorkAppError(RuntimeError): pass @@ -1022,71 +1092,6921 @@ def run_agent_work_json(*args: str, timeout: int = 20, backend: str | None = Non return payload if isinstance(payload, dict) else {} -def run_list() -> dict[str, Any]: - try: - payload = run_agent_work_json("runs", "--json", "--active") - except Exception as exc: - return {"runs": [], "count": 0, "error": str(exc)} - runs = payload.get("runs") or [] - live = [] - stale = [] - by_pool: dict[str, int] = {"builder": 0, "validator": 0, "small": 0, "coordinator": 0} - for item in runs: - status = str(item.get("status") or "") - health = str(item.get("health") or "") - is_live = status in {"planned", "launching", "running", "untracked_interactive"} and ( - bool(item.get("pid_alive")) or bool(item.get("tmux_alive")) or status == "untracked_interactive" - ) - (live if is_live else stale).append(item) - if not is_live: - continue - agent = str(item.get("agent") or "") - role = str(item.get("role") or "") - if agent.startswith("small-worker"): - by_pool["small"] += 1 - elif role in by_pool: - by_pool[role] += 1 - elif health == "running": - by_pool["builder"] += 1 - payload["runs"] = runs - payload["count"] = len(runs) - manager_summary: dict[str, Any] = {} - try: - manager = subprocess.run( - [sys.executable, str(ROOT_DIR / "scripts" / "agent_manager.py"), "scan", "--json"], - cwd=ROOT_DIR, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=10, - check=False, +def run_list() -> dict[str, Any]: + try: + payload = run_agent_work_json("runs", "--json", "--active") + except Exception as exc: + return {"runs": [], "count": 0, "error": str(exc)} + runs = payload.get("runs") or [] + live = [] + stale = [] + by_pool: dict[str, int] = {"builder": 0, "validator": 0, "small": 0, "coordinator": 0} + for item in runs: + status = str(item.get("status") or "") + health = str(item.get("health") or "") + is_live = status in {"planned", "launching", "running", "untracked_interactive"} and ( + bool(item.get("pid_alive")) or bool(item.get("tmux_alive")) or status == "untracked_interactive" + ) + (live if is_live else stale).append(item) + if not is_live: + continue + agent = str(item.get("agent") or "") + role = str(item.get("role") or "") + if agent.startswith("small-worker"): + by_pool["small"] += 1 + elif role in by_pool: + by_pool[role] += 1 + elif health == "running": + by_pool["builder"] += 1 + payload["runs"] = runs + payload["count"] = len(runs) + manager_summary: dict[str, Any] = {} + try: + manager = subprocess.run( + [sys.executable, str(ROOT_DIR / "scripts" / "agent_manager.py"), "scan", "--json"], + cwd=ROOT_DIR, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + if manager.returncode == 0: + manager_payload = json.loads(manager.stdout) + if isinstance(manager_payload, dict) and isinstance(manager_payload.get("summary"), dict): + manager_summary = manager_payload["summary"] + except Exception: + manager_summary = {} + payload["summary"] = { + "live": len(live), + "stale": len(stale), + "actionable_stale": int(manager_summary.get("actionable_stale", len(stale)) or 0), + "historical_stale": int(manager_summary.get("historical_stale", 0) or 0), + "archived": int(manager_summary.get("archived", 0) or 0), + "manual": int(manager_summary.get("manual", 0) or 0), + "risk_count": int(manager_summary.get("risk_count", 0) or 0), + "by_pool": by_pool, + "targets": {"builder": 4, "validator": 3, "small": 3, "coordinator": 1}, + } + return payload + + +def read_json_path(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def write_json_path(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f".{path.name}.tmp") + tmp_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(path) + + +def title_status(value: str, fallback: str = "") -> str: + raw = str(value or fallback or "").strip() + if not raw: + return "" + return raw.replace("_", " ").replace("-", " ").title() + + +def event_count(path: Path) -> int: + try: + return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.strip()) + except OSError: + return 0 + + +def read_event_rows(path: Path, limit: int = 120) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return rows + for line in lines[-limit:]: + if not line.strip(): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + rows.append(payload) + return rows + + +def parse_iso_datetime(value: Any) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + if raw.endswith("Z"): + raw = f"{raw[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(raw) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def format_run_time(value: datetime | None, include_date: bool = True) -> str: + if value is None: + return "" + local = value.astimezone() + if include_date: + text = local.strftime("%b %d, %Y %I:%M:%S %p") + else: + text = local.strftime("%I:%M:%S %p") + return text.replace(" 0", " ") + + +def duration_seconds_from_label(value: Any, fallback: int) -> int: + text = str(value or "").strip().lower() + if not text: + return fallback + total = 0 + saw_number = False + for token in text.replace(",", " ").split(): + number = "".join(char for char in token if char.isdigit()) + if not number: + continue + saw_number = True + amount = int(number) + if "h" in token: + total += amount * 3600 + elif "m" in token: + total += amount * 60 + else: + total += amount + return total if saw_number else fallback + + +def duration_label(seconds: int) -> str: + seconds = max(0, int(seconds or 0)) + minutes, remainder = divmod(seconds, 60) + if minutes: + return f"{minutes}m {remainder:02d}s" + return f"{remainder}s" + + +def file_size_label(path: Path) -> str: + try: + size = path.stat().st_size + except OSError: + return "missing" + if size >= 1024 * 1024: + return f"{size / (1024 * 1024):.1f} MB" + if size >= 1024: + return f"{size / 1024:.1f} KB" + return f"{size} B" + + +def dev_pipeline_relative(path: Path) -> str: + try: + return str(path.resolve().relative_to(ROOT_DIR.resolve())) + except ValueError: + return str(path) + + +def dev_pipeline_root_path(root: Path, relative: str) -> Path: + clean = str(relative or "").strip().lstrip("/") + if not clean: + raise AgentWorkAppError("pipeline artifact path is required") + path = (root / clean).resolve() + root_resolved = root.resolve() + if path != root_resolved and root_resolved not in path.parents: + raise AgentWorkAppError("pipeline artifact path is outside the studio root") + return path + + +def dev_pipeline_slug(value: str, fallback: str) -> str: + raw = str(value or fallback or "").strip().lower() + chars: list[str] = [] + previous_dash = False + for char in raw: + if char.isalnum(): + chars.append(char) + previous_dash = False + elif not previous_dash: + chars.append("-") + previous_dash = True + slug = "".join(chars).strip("-") + return slug or fallback + + +def dev_pipeline_unique_id(items: list[dict[str, Any]], base_id: str) -> str: + existing = {str(item.get("id") or "") for item in items} + candidate = base_id + index = 2 + while candidate in existing: + candidate = f"{base_id}-{index}" + index += 1 + return candidate + + +def dev_pipeline_text(value: Any, current: str = "") -> str: + if value is None: + return current + return str(value).strip() + + +def dev_pipeline_float(value: Any, current: float) -> float: + if value is None or value == "": + return current + try: + return float(value) + except (TypeError, ValueError): + return current + + +def dev_pipeline_text_list(value: Any, current: list[str]) -> list[str]: + if value is None: + return current + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [line.strip() for line in str(value).splitlines() if line.strip()] + + +DEV_PIPELINE_INPUT_TYPES = {"text", "details", "image", "questionnaire", "path", "evidence"} +PIPELINE_RUN_INPUT_TYPES = {"text", "questionnaire", "path", "image", "details", "evidence"} +PIPELINE_RUN_SCHEMA_VERSION = "cento.pipeline_run_request.v1" + + +def dev_pipeline_input_source(value: Any, fallback: str = "user") -> str: + source = dev_pipeline_text(value, fallback).lower().replace("_", "-").replace(" ", "-") + aliases = { + "operator": "user", + "manual": "user", + "generated": "auto", + "automation": "auto", + "automated": "auto", + } + source = aliases.get(source, source) + return source if source in {"user", "auto"} else fallback + + +def dev_pipeline_input_type(value: Any, fallback: str = "text") -> str: + raw = dev_pipeline_text(value, fallback) or fallback + kind = raw.lower().replace("_", "-").replace(" ", "-") + aliases = { + "detail": "details", + "images": "image", + "screenshot": "image", + "mockup": "image", + "questions": "questionnaire", + "question": "questionnaire", + "form": "questionnaire", + "paths": "path", + "route": "path", + "routes": "path", + "command": "path", + "artifact": "evidence", + "artifacts": "evidence", + "receipt": "evidence", + } + kind = aliases.get(kind, kind) + if kind in DEV_PIPELINE_INPUT_TYPES: + return kind + return fallback if fallback in DEV_PIPELINE_INPUT_TYPES else "text" + + +def dev_pipeline_inferred_input_type(input_id: str, title: str) -> str: + text = f"{input_id} {title}".lower() + if any(token in text for token in ("image", "screenshot", "mockup", "visual", "reference")): + return "image" + if any(token in text for token in ("questionnaire", "question", "acceptance", "criteria")): + return "questionnaire" + if any(token in text for token in ("path", "surface", "route", "command", "file")): + return "path" + if any(token in text for token in ("evidence", "receipt", "artifact", "validation")): + return "evidence" + if any(token in text for token in ("detail", "brief", "objective", "constraint")): + return "details" + return "text" + + +def dev_pipeline_question_items(value: Any, current: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + raw_items = value if isinstance(value, list) else current if isinstance(current, list) else [] + questions: list[dict[str, Any]] = [] + for index, item in enumerate(raw_items, start=1): + if isinstance(item, str): + prompt = item.strip() + source: dict[str, Any] = {} + elif isinstance(item, dict): + source = item + prompt = dev_pipeline_text(item.get("prompt", item.get("question")), "") + else: + continue + if not prompt: + continue + options = source.get("options") if isinstance(source, dict) else [] + questions.append( + { + "id": dev_pipeline_slug(dev_pipeline_text(source.get("id") if isinstance(source, dict) else "", f"q-{index}"), f"q-{index}"), + "prompt": prompt, + "required": bool(source.get("required", True)) if isinstance(source, dict) else True, + "answer_type": dev_pipeline_text(source.get("answer_type", source.get("type")) if isinstance(source, dict) else "", "text"), + "options": dev_pipeline_text_list(options, []), + } + ) + return questions + + +def dev_pipeline_required_inputs(value: Any, current: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + raw_items = value if isinstance(value, list) else current if isinstance(current, list) else [] + inputs: list[dict[str, Any]] = [] + for index, item in enumerate(raw_items, start=1): + if not isinstance(item, dict): + continue + title = dev_pipeline_text(item.get("title"), "") + if not title: + continue + status = dev_pipeline_text(item.get("status"), "Missing").lower().replace("_", "-") + if status not in {"provided", "configured", "missing", "optional", "muted", "skipped", "blocking-config"}: + status = "missing" + item_id = dev_pipeline_slug(dev_pipeline_text(item.get("id"), title), f"input-{index}") + kind = dev_pipeline_input_type(item.get("kind", item.get("input_type", item.get("type"))), dev_pipeline_inferred_input_type(item_id, title)) + source = dev_pipeline_input_source(item.get("source", item.get("automation_source")), "user") + automation = dev_pipeline_text(item.get("automation", item.get("automation_source")), "") + normalized = { + "id": item_id, + "title": title, + "detail": dev_pipeline_text(item.get("detail"), ""), + "kind": kind, + "input_type": kind, + "source": source, + "automation": automation, + "automation_source": automation, + "muted": bool(item.get("muted", status == "muted")), + "blocking": bool(item.get("blocking", not bool(item.get("muted", status == "muted")))), + "format": dev_pipeline_text(item.get("format"), ""), + "status": status, + "required": bool(item.get("required", status != "optional")), + "advanced": bool(item.get("advanced", False)), + "image_refs": dev_pipeline_text_list(item.get("image_refs", item.get("images", item.get("references"))), []), + "image_notes": dev_pipeline_text(item.get("image_notes", item.get("reference_notes")), ""), + "questions": dev_pipeline_question_items(item.get("questions", item.get("questionnaire"))), + "paths": dev_pipeline_text_list(item.get("paths", item.get("target_paths", item.get("routes"))), []), + "path_policy": dev_pipeline_text(item.get("path_policy", item.get("ownership_policy")), ""), + "artifacts": dev_pipeline_text_list(item.get("artifacts", item.get("evidence_artifacts")), []), + "evidence_policy": dev_pipeline_text(item.get("evidence_policy", item.get("validation_policy")), ""), + "answer": dev_pipeline_text(item.get("answer", item.get("provided_answer", item.get("value"))), ""), + "answer_values": dev_pipeline_text_list(item.get("answer_values", item.get("provided_values", item.get("provided_paths"))), []), + "answer_notes": dev_pipeline_text(item.get("answer_notes", item.get("provided_notes")), ""), + "provided_at": dev_pipeline_text(item.get("provided_at"), ""), + "manifest": dev_pipeline_text(item.get("manifest"), ""), + } + normalized["answer_present"] = bool( + str(normalized["answer"]).strip() + or normalized["answer_values"] + or str(normalized["answer_notes"]).strip() + or bool(item.get("answer_present", False)) + ) + if not normalized["format"]: + normalized["format"] = { + "text": "plain text", + "details": "markdown", + "image": "image reference list", + "questionnaire": "structured answers", + "path": "path list", + "evidence": "artifact list", + }.get(kind, "plain text") + inputs.append(normalized) + return inputs + + +def dev_pipeline_validation_status(value: Any, current: str = "configured") -> str: + status = dev_pipeline_text(value, current).lower().replace("_", "-").replace(" ", "-") + if status not in {"passed", "configured", "queued", "warning", "failed", "manual-review"}: + status = current if current in {"passed", "configured", "queued", "warning", "failed", "manual-review"} else "configured" + return status + + +def dev_pipeline_integration_status(value: Any, current: str = "accepted") -> str: + status = dev_pipeline_text(value, current).lower().replace("_", "-").replace(" ", "-") + if status not in {"accepted", "configured", "queued", "merged", "blocked", "rejected"}: + status = current if current in {"accepted", "configured", "queued", "merged", "blocked", "rejected"} else "configured" + return status + + +def dev_pipeline_integration_mode(value: Any = "") -> str: + mode = dev_pipeline_text(value, "sequential").lower().replace("_", "-").replace(" ", "-") + if mode in {"sequential", "dependency-order", "deterministic", "batch", "manual-gate"}: + return mode + return "dependency-order" + + +def dev_pipeline_integration_gate_from_check(check: dict[str, Any]) -> str: + name = dev_pipeline_text(check.get("name"), "").lower().replace("_", "-") + status = dev_pipeline_text(check.get("status"), "passed").lower().replace("_", "-") + details = dev_pipeline_text(check.get("details"), "") + gate_map = { + "acceptance-criteria": "Acceptance criteria are captured", + "deterministic-checks": "Deterministic validation checks are declared", + "handoff-complete": "Handoff evidence is complete", + "no-owned-path-overlap": "No owned-path overlap", + "owned-path": "Owned path receipt recorded", + "plan-integration-receipt-accepted": "Plan integration receipt accepted", + "plan-scope": "Implementation plan scope is accepted", + "read-paths-indexed": "Cento context and read paths are indexed", + "rollback-plan-recorded": "Rollback plan recorded", + "schema": "Schema receipt recorded", + } + gate = gate_map.get(name, "") + if not gate: + if details and "workspace/runs/" not in details and not details.endswith(".json"): + gate = details + elif name: + gate = name.replace("-", " ").title() + else: + gate = "Receipt check recorded" + if status not in {"passed", "accepted"}: + gate = f"{gate} ({status})" + return gate + + +def dev_pipeline_integration_config( + root: Path, + project: dict[str, Any], + template: dict[str, Any], + worker: dict[str, Any], + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + worker_id = dev_pipeline_slug(dev_pipeline_text((payload or {}).get("id"), str(worker.get("id") or "")), "integration") + receipt_rel = dev_pipeline_text((payload or {}).get("receipt"), str(worker.get("integration_receipt") or f"integration_receipts/{template.get('id')}_{worker_id}.json")) + config_rel = dev_pipeline_text((payload or {}).get("config_path"), str(worker.get("integration_config") or f"integration/configs/{worker_id}.json")) + receipt = dev_pipeline_artifact_json(root, receipt_rel) + existing_config = dev_pipeline_artifact_json(root, config_rel) + current = existing_config if existing_config else {} + source = payload if isinstance(payload, dict) else current + title = dev_pipeline_text(source.get("title"), f"Integrate: {worker.get('file') or f'{worker_id}.json'}") + status = dev_pipeline_integration_status(source.get("status"), str(current.get("status") or receipt.get("status") or "accepted")) + dependencies = dev_pipeline_text_list(source.get("dependencies"), [str(value) for value in worker.get("dependencies", []) if isinstance(value, str)]) + artifacts = dev_pipeline_text_list(source.get("artifacts"), [str(item) for item in receipt.get("changed_files", []) if isinstance(item, str)]) + if not artifacts: + artifacts = [f"{project.get('owned_root') or 'workspace/runs/generic-task/outputs'}/{worker.get('file') or f'{worker_id}.json'}"] + receipt_gates = [dev_pipeline_integration_gate_from_check(item) for item in receipt.get("checks", []) if isinstance(item, dict)] + gates = dev_pipeline_text_list(source.get("gates"), [item for item in receipt_gates if item]) + if not gates: + gates = ["Dependencies integrated first", "No owned-path conflict", "Receipt written before validation starts"] + rollback_plan = dev_pipeline_text_list(source.get("rollback_plan"), [str(item) for item in current.get("rollback_plan", []) if isinstance(item, str)]) + if not rollback_plan: + rollback_plan = ["Leave previous receipt untouched until apply succeeds", "Reject this integration step and preserve worker artifact for retry"] + if dependencies: + default_apply_policy = f"Apply this worker artifact after {', '.join(dependencies)} integration receipt{'s are' if len(dependencies) != 1 else ' is'} accepted" + else: + default_apply_policy = "Apply this worker artifact after pipeline and workset manifests are valid" + return { + "schema_version": "cento.integration_config.v1", + "id": worker_id, + "project": str(project.get("id") or ""), + "template_id": str(template.get("id") or ""), + "title": title, + "worker_file": str(worker.get("file") or f"{worker_id}.json"), + "status": status, + "mode": dev_pipeline_integration_mode(source.get("mode", current.get("mode", ""))), + "apply_policy": dev_pipeline_text(source.get("apply_policy"), str(current.get("apply_policy") or default_apply_policy)), + "conflict_policy": dev_pipeline_text(source.get("conflict_policy"), str(current.get("conflict_policy") or "Block on overlapping owned paths, rejected dependency receipts, or a missing rollback plan")), + "dependencies": dependencies, + "artifacts": artifacts, + "gates": gates, + "rollback_plan": rollback_plan, + "receipt": receipt_rel, + "config_path": config_rel, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + + +def dev_pipeline_write_integration_outputs(root: Path, manifest: dict[str, Any], project: dict[str, Any], template: dict[str, Any], config: dict[str, Any]) -> None: + worker_id = str(config.get("id") or "integration") + workers = [item for item in template.get("workers", []) if isinstance(item, dict)] + worker = next((item for item in workers if str(item.get("id") or "") == worker_id), None) + if worker is None: + worker = {"id": worker_id, "file": str(config.get("worker_file") or f"{worker_id}.json")} + workers.append(worker) + template["workers"] = workers + worker["integration_config"] = str(config.get("config_path") or f"integration/configs/{worker_id}.json") + worker["integration_receipt"] = str(config.get("receipt") or f"integration_receipts/{template.get('id')}_{worker_id}.json") + worker["dependencies"] = [str(value) for value in config.get("dependencies", []) if isinstance(value, str)] + write_json_path(dev_pipeline_root_path(root, str(worker["integration_config"])), config) + + receipt_payload = { + "schema_version": "cento.integration_receipt.v1", + "manifest_id": str(manifest.get("id") or ""), + "worker_id": worker_id, + "template_id": str(template.get("id") or ""), + "status": str(config.get("status") or "configured"), + "mode": str(config.get("mode") or "dependency-order"), + "apply_policy": str(config.get("apply_policy") or ""), + "conflict_policy": str(config.get("conflict_policy") or ""), + "dependencies": [str(value) for value in config.get("dependencies", []) if isinstance(value, str)], + "changed_files": [str(value) for value in config.get("artifacts", []) if isinstance(value, str)], + "checks": [ + {"name": dev_pipeline_slug(value, f"gate-{index}"), "status": "passed", "details": value} + for index, value in enumerate([str(item) for item in config.get("gates", []) if isinstance(item, str)], start=1) + ], + "rollback_plan": [str(value) for value in config.get("rollback_plan", []) if isinstance(value, str)], + "config": str(worker["integration_config"]), + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, str(worker["integration_receipt"])), receipt_payload) + + lane_steps: list[dict[str, Any]] = [] + for item in workers: + item_config = dev_pipeline_integration_config(root, project, template, item) + lane_steps.append( + { + "id": str(item_config.get("id") or ""), + "title": str(item_config.get("title") or ""), + "mode": str(item_config.get("mode") or ""), + "status": str(item_config.get("status") or ""), + "dependencies": [str(value) for value in item_config.get("dependencies", []) if isinstance(value, str)], + "artifacts": [str(value) for value in item_config.get("artifacts", []) if isinstance(value, str)], + "gates": [str(value) for value in item_config.get("gates", []) if isinstance(value, str)], + "rollback_plan": [str(value) for value in item_config.get("rollback_plan", []) if isinstance(value, str)], + "config": str(item_config.get("config_path") or ""), + "receipt": str(item_config.get("receipt") or ""), + } + ) + lane_status = "configured" + statuses = [str(item.get("status") or "") for item in lane_steps] + if any(status in {"blocked", "rejected"} for status in statuses): + lane_status = "blocked" + elif statuses and all(status in {"accepted", "merged"} for status in statuses): + lane_status = "accepted" + integration_lane = { + "schema_version": "cento.integration_lane.v1", + "id": f"{template.get('id') or 'pipeline'}-integration-lane", + "project": str(project.get("id") or ""), + "template_id": str(template.get("id") or ""), + "mode": "dependency-ordered-apply", + "status": lane_status, + "apply_policy": "Integrate worker artifacts after declared dependencies and before validation validators run", + "conflict_policy": "Block on overlapping owned paths, rejected receipts, or missing dependency outputs", + "steps": lane_steps, + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, "integration/integration_lane.json"), integration_lane) + + +def dev_pipeline_write_factory_step_outputs(root: Path, manifest: dict[str, Any], project: dict[str, Any], template: dict[str, Any], config: dict[str, Any]) -> None: + step_id = str(config.get("id") or "factory-step") + factory_steps = [item for item in template.get("factory_steps", []) if isinstance(item, dict)] + step = next((item for item in factory_steps if str(item.get("id") or "") == step_id), None) + if step is None: + step = {"id": step_id, "title": str(config.get("title") or step_id), "file": str(config.get("worker_file") or f"{step_id}.json")} + factory_steps.append(step) + template["factory_steps"] = factory_steps + step["title"] = str(config.get("title") or step.get("title") or step_id) + step["file"] = str(config.get("worker_file") or step.get("file") or f"{step_id}.json") + step["status"] = str(config.get("status") or step.get("status") or "configured") + step["mode"] = str(config.get("mode") or "dependency-order") + step["integration_config"] = str(config.get("config_path") or f"integration/configs/{step_id}.json") + step["integration_receipt"] = str(config.get("receipt") or f"integration_receipts/{template.get('id')}_{step_id}.json") + step["dependencies"] = [str(value) for value in config.get("dependencies", []) if isinstance(value, str)] + step["artifacts"] = [str(value) for value in config.get("artifacts", []) if isinstance(value, str)] + step["gates"] = [str(value) for value in config.get("gates", []) if isinstance(value, str)] + step["rollback_plan"] = [str(value) for value in config.get("rollback_plan", []) if isinstance(value, str)] + write_json_path(dev_pipeline_root_path(root, str(step["integration_config"])), config) + + receipt_payload = { + "schema_version": "cento.factory_step_receipt.v1", + "manifest_id": str(manifest.get("id") or ""), + "step_id": step_id, + "template_id": str(template.get("id") or ""), + "project": str(project.get("id") or ""), + "status": str(config.get("status") or "configured"), + "mode": str(config.get("mode") or "dependency-order"), + "apply_policy": str(config.get("apply_policy") or ""), + "conflict_policy": str(config.get("conflict_policy") or ""), + "dependencies": [str(value) for value in config.get("dependencies", []) if isinstance(value, str)], + "artifacts": [str(value) for value in config.get("artifacts", []) if isinstance(value, str)], + "checks": [ + {"name": dev_pipeline_slug(value, f"gate-{index}"), "status": "passed", "details": value} + for index, value in enumerate([str(item) for item in config.get("gates", []) if isinstance(item, str)], start=1) + ], + "rollback_plan": [str(value) for value in config.get("rollback_plan", []) if isinstance(value, str)], + "config": str(step["integration_config"]), + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, str(step["integration_receipt"])), receipt_payload) + + execution_manifest_rel = str(template.get("execution_manifest") or "execution/execution_manifest.json") + execution_manifest = { + "schema_version": "cento.execution_manifest.v1", + "manifest_id": str(manifest.get("id") or ""), + "project": str(project.get("id") or ""), + "template_id": str(template.get("id") or ""), + "rollback_on_failure": True, + "max_changed_files": 8, + "steps": [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or item.get("id") or ""), + "file": str(item.get("file") or ""), + "status": str(item.get("status") or ""), + "dependencies": [str(value) for value in item.get("dependencies", []) if isinstance(value, str)], + "config": str(item.get("integration_config") or ""), + "receipt": str(item.get("integration_receipt") or ""), + } + for item in factory_steps + ], + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, execution_manifest_rel), execution_manifest) + + +def dev_pipeline_default_validation_commands(validator_id: str) -> list[str]: + if validator_id in {"smoke", "smoke-plus"}: + return [ + "python3 -m json.tool workspace/runs/dev-pipeline-studio/docs-pages/latest/pipeline_manifest.json", + "node --check templates/agent-work-app/app.js", + "curl -fsS http://127.0.0.1:47910/api/dev-pipeline-studio?project=generic-easy-medium-task\\&template=generic-task", + ] + if validator_id in {"contract", "schema"}: + return [ + "python3 -m json.tool workspace/runs/dev-pipeline-studio/docs-pages/latest/workset.json", + "python3 -m json.tool workspace/runs/dev-pipeline-studio/docs-pages/latest/pipeline_manifest.json", + ] + if validator_id in {"screenshot", "evidence"}: + return [ + "npx --yes playwright screenshot http://127.0.0.1:47910/dev-pipeline-studio workspace/runs/agent-work/dev-pipeline-studio-validation-config/validation-inspector.png", + ] + return [] + + +def dev_pipeline_validator_mode(validator_id: str, value: Any = "") -> str: + mode = dev_pipeline_text(value, "").lower().replace("_", "-").replace(" ", "-") + if mode in {"commands", "evidence", "gates", "schema"}: + return mode + if validator_id in {"contract", "schema"}: + return "schema" + if validator_id in {"screenshot", "evidence"}: + return "evidence" + return "commands" + + +def dev_pipeline_validator_config( + root: Path, + project: dict[str, Any], + template: dict[str, Any], + validator: dict[str, Any], + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + validator_id = dev_pipeline_slug(dev_pipeline_text((payload or {}).get("id"), str(validator.get("id") or "")), "validator") + receipt_rel = dev_pipeline_text((payload or {}).get("receipt"), str(validator.get("receipt") or f"validation/{validator_id}_receipt.json")) + config_rel = dev_pipeline_text((payload or {}).get("config_path"), str(validator.get("config") or f"validation/validator_configs/{validator_id}.json")) + existing_config = dev_pipeline_artifact_json(root, config_rel) + receipt = dev_pipeline_artifact_json(root, receipt_rel) + current = existing_config if existing_config else {} + source = payload if isinstance(payload, dict) else current + title = dev_pipeline_text(source.get("title"), str(validator.get("title") or validator_id)) + status = dev_pipeline_validation_status(source.get("status"), str(current.get("status") or receipt.get("status") or "configured")) + mode = dev_pipeline_validator_mode(validator_id, source.get("mode", current.get("mode", ""))) + commands = dev_pipeline_text_list(source.get("commands"), [str(item.get("command") or item.get("name") or "") for item in receipt.get("commands", []) if isinstance(item, dict)]) + if not commands: + commands = dev_pipeline_default_validation_commands(validator_id) + evidence = dev_pipeline_text_list(source.get("evidence"), [str(item) for item in receipt.get("evidence", receipt.get("artifacts", [])) if isinstance(item, str)]) + if not evidence and receipt_rel: + evidence = [receipt_rel] + gates = dev_pipeline_text_list(source.get("gates"), [str(item) for item in current.get("gates", []) if isinstance(item, str)]) + if not gates: + gates = ["No failed validation command", "Receipt artifact is attached to evidence bundle", "Blocking validator prevents handoff until resolved"] + schema_paths = dev_pipeline_text_list(source.get("schema_paths"), [str(item) for item in current.get("schema_paths", []) if isinstance(item, str)]) + if not schema_paths and validator_id in {"contract", "schema"}: + schema_paths = ["pipeline_manifest.json", "workset.json", "integration_receipts/*.json"] + summary = dev_pipeline_text(source.get("summary"), str(receipt.get("summary") or f"{title} configured for the integration lane")) + tier = dev_pipeline_text(source.get("tier"), str(template.get("validation_tier") or receipt.get("tier") or "smoke-plus")) + source_results = source.get("results") if isinstance(source.get("results"), dict) else None + receipt_results = receipt.get("results") if isinstance(receipt.get("results"), dict) else {} + results = deepcopy(source_results if isinstance(source_results, dict) else receipt_results) + return { + "schema_version": "cento.validator_config.v1", + "id": validator_id, + "project": str(project.get("id") or ""), + "template_id": str(template.get("id") or ""), + "title": title, + "mode": mode, + "status": status, + "tier": tier, + "summary": summary, + "commands": commands, + "evidence": evidence, + "gates": gates, + "schema_paths": schema_paths, + "blocking": bool(source.get("blocking", current.get("blocking", True))), + "receipt": receipt_rel, + "config_path": config_rel, + "last_run_mode": str(source.get("last_run_mode") or receipt.get("last_run_mode") or ""), + "last_run_status": str(source.get("last_run_status") or receipt.get("last_run_status") or ""), + "executed_at": str(source.get("executed_at") or receipt.get("executed_at") or ""), + "results": results, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + + +def dev_pipeline_result_status(items: list[dict[str, Any]]) -> str: + if not items: + return "configured" + statuses = {str(item.get("status") or "").lower() for item in items} + if "failed" in statuses: + return "failed" + if "warning" in statuses: + return "warning" + if statuses and statuses <= {"passed", "accepted"}: + return "passed" + return "configured" + + +def dev_pipeline_validation_run_status(results: dict[str, Any]) -> str: + statuses: list[str] = [] + for key in ("commands", "evidence", "gates", "schema"): + items = results.get(key) + if isinstance(items, list) and items: + statuses.append(dev_pipeline_result_status([item for item in items if isinstance(item, dict)])) + if not statuses: + return "configured" + if "failed" in statuses: + return "failed" + if "warning" in statuses: + return "warning" + if all(status == "passed" for status in statuses): + return "passed" + return "configured" + + +def dev_pipeline_validation_path(root: Path, value: str) -> Path: + raw = str(value or "").strip() + path = Path(raw) + if path.is_absolute(): + return path + if raw.startswith("workspace/") or raw.startswith("templates/") or raw.startswith("scripts/") or raw.startswith("docs/"): + return ROOT_DIR / raw + return root / raw + + +def dev_pipeline_validation_glob(root: Path, value: str) -> list[Path]: + raw = str(value or "").strip() + if not raw: + return [] + pattern = str(dev_pipeline_validation_path(root, raw)) + matches = [Path(match) for match in glob.glob(pattern)] + if matches: + return sorted(matches) + return [dev_pipeline_validation_path(root, raw)] + + +def dev_pipeline_relative_validation_path(root: Path, path: Path) -> str: + try: + return str(path.resolve().relative_to(ROOT_DIR)) + except ValueError: + try: + return str(path.resolve().relative_to(root.resolve())) + except ValueError: + return str(path) + + +def dev_pipeline_run_command(command: str, index: int) -> dict[str, Any]: + started = time.time() + try: + completed = subprocess.run( + command, + cwd=ROOT_DIR, + shell=True, + text=True, + capture_output=True, + timeout=30, + ) + status = "passed" if completed.returncode == 0 else "failed" + return { + "id": f"command-{index}", + "command": command, + "status": status, + "returncode": completed.returncode, + "duration_ms": int((time.time() - started) * 1000), + "stdout": completed.stdout[-4000:], + "stderr": completed.stderr[-4000:], + } + except subprocess.TimeoutExpired as error: + return { + "id": f"command-{index}", + "command": command, + "status": "failed", + "returncode": 124, + "duration_ms": int((time.time() - started) * 1000), + "stdout": str(error.stdout or "")[-4000:], + "stderr": f"Timed out after 30s\n{str(error.stderr or '')[-3800:]}", + } + + +def dev_pipeline_check_evidence(root: Path, evidence: str, index: int) -> dict[str, Any]: + raw = str(evidence or "").strip() + if raw.startswith(("http://", "https://")): + try: + with urlopen(raw, timeout=5) as response: + status_code = getattr(response, "status", 200) + return {"id": f"evidence-{index}", "path": raw, "status": "passed", "kind": "url", "details": f"HTTP {status_code}"} + except (HTTPError, URLError, TimeoutError) as error: + return {"id": f"evidence-{index}", "path": raw, "status": "failed", "kind": "url", "details": str(error)} + path = dev_pipeline_validation_path(root, raw) + exists = path.exists() + return { + "id": f"evidence-{index}", + "path": raw, + "resolved_path": dev_pipeline_relative_validation_path(root, path), + "status": "passed" if exists else "failed", + "kind": "file", + "details": "exists" if exists else "missing", + } + + +def dev_pipeline_schema_checks(root: Path, schema_paths: list[str]) -> list[dict[str, Any]]: + checks: list[dict[str, Any]] = [] + for index, raw in enumerate(schema_paths, start=1): + paths = dev_pipeline_validation_glob(root, raw) + for match_index, path in enumerate(paths, start=1): + check_id = f"schema-{index}" if len(paths) == 1 else f"schema-{index}-{match_index}" + if not path.exists(): + checks.append( + { + "id": check_id, + "path": raw, + "resolved_path": dev_pipeline_relative_validation_path(root, path), + "status": "failed", + "details": "missing", + } + ) + continue + try: + json.loads(path.read_text(encoding="utf-8")) + checks.append( + { + "id": check_id, + "path": raw, + "resolved_path": dev_pipeline_relative_validation_path(root, path), + "status": "passed", + "details": "valid JSON", + } + ) + except (OSError, json.JSONDecodeError) as error: + checks.append( + { + "id": check_id, + "path": raw, + "resolved_path": dev_pipeline_relative_validation_path(root, path), + "status": "failed", + "details": str(error), + } + ) + return checks + + +def dev_pipeline_gate_checks(root: Path, config: dict[str, Any], results: dict[str, Any]) -> list[dict[str, Any]]: + integration_lane = dev_pipeline_artifact_json(root, "integration/integration_lane.json") + lane_steps = [step for step in integration_lane.get("steps", []) if isinstance(step, dict)] + blocked_or_rejected = [ + str(step.get("id") or step.get("title") or "") + for step in lane_steps + if str(step.get("status") or "").lower() in {"blocked", "rejected"} + ] + command_results = [item for item in results.get("commands", []) if isinstance(item, dict)] + checks: list[dict[str, Any]] = [] + for index, gate in enumerate([str(item) for item in config.get("gates", []) if isinstance(item, str)], start=1): + normalized = gate.lower() + status = "passed" + details = "declared gate is non-empty" + if "no failed validation command" in normalized: + failed = [item.get("id") or item.get("command") for item in command_results if str(item.get("status") or "") == "failed"] + status = "failed" if failed else "passed" + details = "no failed commands" if not failed else f"failed commands: {', '.join(map(str, failed))}" + elif "receipt artifact" in normalized and "attached" in normalized: + receipt = str(config.get("receipt") or "") + evidence = [str(item) for item in config.get("evidence", []) if isinstance(item, str)] + status = "passed" if receipt and (receipt in evidence or dev_pipeline_validation_path(root, receipt).exists()) else "failed" + details = f"receipt {receipt} {'attached or exists' if status == 'passed' else 'not attached'}" + elif "blocking validator" in normalized: + status = "passed" if bool(config.get("blocking", True)) else "failed" + details = "blocking enabled" if status == "passed" else "blocking disabled" + elif "no blocked or rejected" in normalized or "owned-path overlap" in normalized: + status = "failed" if blocked_or_rejected else "passed" + details = "integration lane clear" if not blocked_or_rejected else f"blocked/rejected: {', '.join(blocked_or_rejected)}" + elif normalized.startswith("dependency receipt accepted:"): + dependency = gate.split(":", 1)[-1].strip() + matching = next((step for step in lane_steps if str(step.get("id") or "") == dependency), None) + if matching is None: + status = "warning" + details = f"dependency {dependency} not present in integration lane" + else: + step_status = str(matching.get("status") or "").lower() + status = "passed" if step_status in {"accepted", "merged", "configured"} else "failed" + details = f"dependency {dependency} status {step_status or 'unknown'}" + checks.append({"id": f"gate-{index}", "gate": gate, "status": status, "details": details}) + return checks + + +def dev_pipeline_execute_validation(root: Path, config: dict[str, Any], run_mode: str) -> dict[str, Any]: + mode = "all" if str(run_mode or "").lower() == "all" else dev_pipeline_validator_mode(str(config.get("id") or ""), run_mode) + modes = ["commands", "evidence", "gates", "schema"] if mode == "all" else [mode] + existing_results = config.get("results") if isinstance(config.get("results"), dict) else {} + results = deepcopy(existing_results) + if "commands" in modes: + results["commands"] = [ + dev_pipeline_run_command(command, index) + for index, command in enumerate([str(item) for item in config.get("commands", []) if isinstance(item, str) and item.strip()], start=1) + ] + if "evidence" in modes: + results["evidence"] = [ + dev_pipeline_check_evidence(root, evidence, index) + for index, evidence in enumerate([str(item) for item in config.get("evidence", []) if isinstance(item, str) and item.strip()], start=1) + ] + if "schema" in modes: + results["schema"] = dev_pipeline_schema_checks(root, [str(item) for item in config.get("schema_paths", []) if isinstance(item, str) and item.strip()]) + if "gates" in modes: + results["gates"] = dev_pipeline_gate_checks(root, config, results) + status = dev_pipeline_validation_run_status(results) + executed = deepcopy(config) + executed["results"] = results + executed["status"] = status + executed["last_run_status"] = status + executed["last_run_mode"] = mode + executed["executed_at"] = datetime.now(timezone.utc).isoformat() + return executed + + +def dev_pipeline_write_validation_outputs(root: Path, manifest: dict[str, Any], project: dict[str, Any], template: dict[str, Any], config: dict[str, Any]) -> None: + validator_id = str(config.get("id") or "validator") + validators = [item for item in template.get("validators", []) if isinstance(item, dict)] + validator = next((item for item in validators if str(item.get("id") or "") == validator_id), None) + if validator is None: + validator = {"id": validator_id} + validators.append(validator) + template["validators"] = validators + validator["id"] = validator_id + validator["title"] = str(config.get("title") or validator_id) + validator["file"] = Path(str(config.get("receipt") or f"validation/{validator_id}_receipt.json")).name + validator["receipt"] = str(config.get("receipt") or f"validation/{validator_id}_receipt.json") + validator["config"] = str(config.get("config_path") or f"validation/validator_configs/{validator_id}.json") + validator["mode"] = str(config.get("mode") or "commands") + validator["blocking"] = bool(config.get("blocking", True)) + validator["status"] = str(config.get("status") or "configured") + + config_path = dev_pipeline_root_path(root, str(validator["config"])) + write_json_path(config_path, config) + + results = config.get("results") if isinstance(config.get("results"), dict) else {} + command_results = [item for item in results.get("commands", []) if isinstance(item, dict)] + command_status_by_text = { + str(item.get("command") or ""): str(item.get("status") or config.get("status") or "configured") + for item in command_results + } + receipt_commands = [ + { + "name": dev_pipeline_slug(command.split()[0] if command else f"command-{index}", f"command-{index}"), + "command": command, + "status": command_status_by_text.get(str(command), str(config.get("status") or "configured")), + } + for index, command in enumerate(config.get("commands") or [], start=1) + ] + receipt_payload = { + "schema_version": "cento.validator_receipt.v1", + "id": validator_id, + "status": str(config.get("status") or "configured"), + "tier": str(config.get("tier") or template.get("validation_tier") or ""), + "summary": str(config.get("summary") or ""), + "mode": str(config.get("mode") or "commands"), + "blocking": bool(config.get("blocking", True)), + "commands": receipt_commands, + "evidence": [str(item) for item in config.get("evidence", []) if isinstance(item, str)], + "gates": [str(item) for item in config.get("gates", []) if isinstance(item, str)], + "schema_paths": [str(item) for item in config.get("schema_paths", []) if isinstance(item, str)], + "results": results, + "last_run_mode": str(config.get("last_run_mode") or ""), + "last_run_status": str(config.get("last_run_status") or ""), + "executed_at": str(config.get("executed_at") or ""), + "config": str(validator["config"]), + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, str(validator["receipt"])), receipt_payload) + + validator_checks: list[dict[str, Any]] = [] + aggregate_commands: list[dict[str, Any]] = [] + aggregate_artifacts: list[str] = [] + aggregate_statuses: list[str] = [] + for item in validators: + item_id = str(item.get("id") or "") + item_config = dev_pipeline_validator_config(root, project, template, item) + aggregate_statuses.append(str(item_config.get("status") or "configured")) + aggregate_artifacts.extend([str(item_config.get("receipt") or ""), str(item_config.get("config_path") or "")]) + validator_checks.append( + { + "id": item_id, + "title": str(item_config.get("title") or item_id), + "type": str(item_config.get("mode") or "commands"), + "status": str(item_config.get("status") or "configured"), + "blocking": bool(item_config.get("blocking", True)), + "commands": [str(value) for value in item_config.get("commands", []) if isinstance(value, str)], + "evidence": [str(value) for value in item_config.get("evidence", []) if isinstance(value, str)], + "gates": [str(value) for value in item_config.get("gates", []) if isinstance(value, str)], + "schema_paths": [str(value) for value in item_config.get("schema_paths", []) if isinstance(value, str)], + "last_run_mode": str(item_config.get("last_run_mode") or ""), + "last_run_status": str(item_config.get("last_run_status") or ""), + "executed_at": str(item_config.get("executed_at") or ""), + "results": item_config.get("results") if isinstance(item_config.get("results"), dict) else {}, + "config": str(item_config.get("config_path") or ""), + "receipt": str(item_config.get("receipt") or ""), + } + ) + for command in item_config.get("commands", []): + if isinstance(command, str) and command.strip(): + aggregate_commands.append( + { + "name": f"{item_id}_{dev_pipeline_slug(command.split()[0], 'command')}", + "command": command, + "status": str(item_config.get("status") or "configured"), + } + ) + + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} + validator_manifest_rel = str(artifacts.get("validator_manifest") or "validation/validator_manifest.json") + validator_manifest = { + "schema_version": "cento.validator_manifest.v1", + "id": f"{template.get('id') or 'pipeline'}-validator", + "pipeline_manifest": "pipeline_manifest.json", + "project": str(project.get("id") or ""), + "template_id": str(template.get("id") or ""), + "validation_tier": str(config.get("tier") or template.get("validation_tier") or ""), + "validation_policy": { + "mode": "post-integration", + "blocking_validators": [str(item.get("id") or "") for item in validators if bool(item.get("blocking", True))], + "receipt_policy": "write validator config, validator receipt, aggregate validation receipt, and evidence references after integration receipts exist", + }, + "checks": validator_checks, + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, validator_manifest_rel), validator_manifest) + + aggregate_status = "configured" + if any(status == "failed" for status in aggregate_statuses): + aggregate_status = "failed" + elif aggregate_statuses and all(status == "passed" for status in aggregate_statuses): + aggregate_status = "passed" + validation_receipt_rel = str(artifacts.get("validation_receipt") or "validation/validation_receipt.json") + validation_receipt = { + "schema_version": "cento.validation_receipt.v1", + "manifest_id": str(manifest.get("id") or ""), + "project": str(project.get("id") or ""), + "template_id": str(template.get("id") or ""), + "tier": str(config.get("tier") or template.get("validation_tier") or ""), + "status": aggregate_status, + "commands": aggregate_commands, + "artifacts": [item for item in dict.fromkeys(aggregate_artifacts) if item], + "validator_manifest": validator_manifest_rel, + "validation_policy": validator_manifest["validation_policy"], + "results": { + str(check.get("id") or ""): check.get("results") if isinstance(check.get("results"), dict) else {} + for check in validator_checks + if str(check.get("id") or "") + }, + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, validation_receipt_rel), validation_receipt) + + +def dev_pipeline_evidence_status(value: Any, current: str = "configured") -> str: + raw = dev_pipeline_text(value, current).lower().replace("_", "-").replace(" ", "-") + if raw.endswith(" events"): + return "logged" + if raw.startswith("$"): + return "within-budget" + aliases = { + "complete": "completed", + "done": "completed", + "attached": "attached", + "review-ready": "review", + "review ready": "review", + "within budget": "within-budget", + "within-budget": "within-budget", + } + status = aliases.get(raw, raw) + allowed = {"completed", "attached", "review", "configured", "logged", "within-budget", "missing", "failed"} + if status in allowed: + return status + return current if current in allowed else "configured" + + +def dev_pipeline_evidence_kind(value: Any, evidence_id: str = "") -> str: + raw = dev_pipeline_text(value, "").lower().replace("_", "-").replace(" ", "-") + aliases = { + "events": "event-log", + "log": "event-log", + "receipt": "receipt", + "pipeline-receipt": "receipt", + "evidence-bundle": "bundle", + "bundle": "bundle", + "budget-receipt": "budget", + "taskstream-evidence": "taskstream", + } + kind = aliases.get(raw, raw) + if kind in {"receipt", "event-log", "bundle", "budget", "taskstream", "artifact"}: + return kind + evidence_id = str(evidence_id or "").replace("_", "-") + if evidence_id == "events": + return "event-log" + if evidence_id in {"evidence-bundle", "evidence_bundle"}: + return "bundle" + if evidence_id == "budget": + return "budget" + if evidence_id == "taskstream": + return "taskstream" + if evidence_id in {"pipeline-receipt", "pipeline_receipt"}: + return "receipt" + return "artifact" + + +def dev_pipeline_default_evidence_sources(evidence_id: str) -> list[str]: + defaults = { + "pipeline_receipt": ["pipeline_manifest.json", "workset.json", "validation/validation_receipt.json"], + "pipeline-receipt": ["pipeline_manifest.json", "workset.json", "validation/validation_receipt.json"], + "events": ["events.ndjson"], + "evidence_bundle": ["pipeline_manifest.json", "integration_receipts/*.json", "validation/validation_receipt.json", "evidence/budget_receipt.json"], + "evidence-bundle": ["pipeline_manifest.json", "integration_receipts/*.json", "validation/validation_receipt.json", "evidence/budget_receipt.json"], + "budget": ["evidence/budget_receipt.json", "pipeline_manifest.json"], + "taskstream": ["evidence/taskstream_evidence.json", "workspace/runs/agent-work/*/validation-report.json"], + } + return defaults.get(str(evidence_id or ""), ["pipeline_manifest.json"]) + + +def dev_pipeline_evidence_config( + root: Path, + manifest: dict[str, Any], + project: dict[str, Any], + template: dict[str, Any], + artifact: dict[str, Any], + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + evidence_id = dev_pipeline_slug(dev_pipeline_text((payload or {}).get("id"), str(artifact.get("id") or "")), "evidence") + path_rel = dev_pipeline_text((payload or {}).get("path"), str(artifact.get("path") or f"evidence/{evidence_id}.json")) + config_rel = dev_pipeline_text((payload or {}).get("config_path"), str(artifact.get("config") or f"evidence/configs/{evidence_id}.json")) + existing_config = dev_pipeline_artifact_json(root, config_rel) + artifact_json = dev_pipeline_artifact_json(root, path_rel) if path_rel.endswith(".json") else {} + current = existing_config if existing_config else {} + source = payload if isinstance(payload, dict) else current + title = dev_pipeline_text(source.get("title"), str(artifact.get("title") or evidence_id.replace("-", " ").title())) + status = dev_pipeline_evidence_status(source.get("status"), dev_pipeline_evidence_status(current.get("status"), dev_pipeline_evidence_status(artifact_json.get("status"), dev_pipeline_evidence_status(artifact.get("state") or artifact.get("status"), "configured")))) + kind = dev_pipeline_evidence_kind(source.get("kind", current.get("kind", artifact.get("kind", ""))), evidence_id) + required_sources = dev_pipeline_text_list(source.get("required_sources"), [str(item) for item in current.get("required_sources", []) if isinstance(item, str)]) + if not required_sources: + required_sources = dev_pipeline_default_evidence_sources(str(artifact.get("id") or evidence_id)) + publish_policy = dev_pipeline_text(source.get("publish_policy"), str(current.get("publish_policy") or "Attach to evidence bundle before Taskstream review")) + retention_policy = dev_pipeline_text(source.get("retention_policy"), str(current.get("retention_policy") or "Keep with the pipeline run artifacts")) + review_notes = dev_pipeline_text(source.get("review_notes"), str(current.get("review_notes") or "")) + return { + "schema_version": "cento.evidence_config.v1", + "id": evidence_id, + "project": str(project.get("id") or ""), + "template_id": str(template.get("id") or ""), + "title": title, + "kind": kind, + "status": status, + "path": path_rel, + "config_path": config_rel, + "required_sources": required_sources, + "publish_policy": publish_policy, + "retention_policy": retention_policy, + "review_notes": review_notes, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + + +def dev_pipeline_write_evidence_outputs(root: Path, manifest: dict[str, Any], project: dict[str, Any], template: dict[str, Any], config: dict[str, Any]) -> None: + config_path = dev_pipeline_root_path(root, str(config.get("config_path") or f"evidence/configs/{config.get('id') or 'evidence'}.json")) + artifact_rel = str(config.get("path") or "") + artifact_path = dev_pipeline_root_path(root, artifact_rel) + write_json_path(config_path, config) + + if artifact_rel.endswith(".ndjson"): + dev_pipeline_append_event( + root, + manifest, + "pipeline_evidence_configured", + str(project.get("id") or ""), + str(template.get("id") or ""), + { + "evidence_id": str(config.get("id") or ""), + "title": str(config.get("title") or ""), + "status": str(config.get("status") or ""), + "config": str(config.get("config_path") or ""), + }, + ) + else: + existing = read_json_path(artifact_path) + payload = deepcopy(existing) if existing else {} + payload.setdefault("schema_version", f"cento.{str(config.get('kind') or 'evidence').replace('-', '_')}.v1") + payload["title"] = str(config.get("title") or "") + payload["status"] = str(config.get("status") or "configured") + payload["evidence_config"] = str(config.get("config_path") or "") + payload["required_sources"] = [str(item) for item in config.get("required_sources", []) if isinstance(item, str)] + payload["publish_policy"] = str(config.get("publish_policy") or "") + payload["retention_policy"] = str(config.get("retention_policy") or "") + payload["review_notes"] = str(config.get("review_notes") or "") + payload["updated_at"] = datetime.now(timezone.utc).isoformat() + write_json_path(artifact_path, payload) + + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} + artifacts["evidence_manifest"] = str(artifacts.get("evidence_manifest") or "evidence/evidence_manifest.json") + manifest["artifacts"] = artifacts + manifest_config_rel = str(artifacts["evidence_manifest"]) + known_configs = sorted({str(config.get("config_path") or "")} | set(str(item) for item in dev_pipeline_artifact_json(root, manifest_config_rel).get("configs", []) if isinstance(item, str))) + evidence_manifest = { + "schema_version": "cento.evidence_manifest.v1", + "project": str(project.get("id") or ""), + "template_id": str(template.get("id") or ""), + "status": "configured", + "configs": [item for item in known_configs if item], + "updated_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, manifest_config_rel), evidence_manifest) + + +def dev_pipeline_generic_blueprint_defaults() -> dict[str, Any]: + return { + "label": "Generic easy task", + "detail": "Fully configured non-UI easy programming blueprint", + "description": "Bounded programming task contracts with deterministic discovery, Factory execution, validation, and evidence handoff for one small repo-local change.", + "tagline": "Small scoped repo change with validation evidence", + "worker_type": "automation_contract_worker", + "execution_model": "ordered", + "worker_stage_label": "2. Repo Discovery", + "factory_stage_label": "4. Factory Execution", + "selected_worker": "repo-context", + "blueprint_version": "automation-contracts.v1", + "tasks_completed": 9, + "tasks_total": 9, + "workers": [ + { + "id": "repo-context", + "title": "Repo Context Manifest", + "file": "repo_context.json", + "description": "Discover languages, test commands, ownership hints, and dependency graph source", + "stage": "repo", + "manifest": "workers/generic-task_repo-context.json", + "integration_config": "integration/configs/repo-context.json", + "integration_receipt": "integration_receipts/generic-task_repo-context.json", + }, + { + "id": "change-blueprint", + "title": "Change Plan Contract", + "file": "change_plan.json", + "description": "Define bounded change units, test units, and optional AI review gates", + "stage": "blueprint", + "dependencies": ["repo-context"], + "manifest": "workers/generic-task_change-blueprint.json", + "integration_config": "integration/configs/change-blueprint.json", + "integration_receipt": "integration_receipts/generic-task_change-blueprint.json", + }, + ], + "factory_steps": [ + {"id": "checkout-branch", "title": "checkout_branch", "file": "execution_manifest.json", "status": "accepted", "mode": "deterministic"}, + {"id": "snapshot-repo-state", "title": "snapshot_repo_state", "file": "repo_snapshot.json", "status": "accepted", "mode": "deterministic", "dependencies": ["checkout-branch"]}, + {"id": "apply-change-units", "title": "apply_change_units", "file": "factory_apply_receipt.json", "status": "accepted", "mode": "deterministic", "dependencies": ["snapshot-repo-state"]}, + {"id": "run-formatters", "title": "run_formatters", "file": "format_receipt.json", "status": "accepted", "mode": "deterministic", "dependencies": ["apply-change-units"]}, + {"id": "run-focused-tests", "title": "run_focused_tests", "file": "focused_tests.log", "status": "accepted", "mode": "deterministic", "dependencies": ["run-formatters"]}, + {"id": "run-full-tests", "title": "run_full_tests", "file": "full_tests.log", "status": "accepted", "mode": "deterministic", "dependencies": ["run-focused-tests"]}, + {"id": "collect-diff", "title": "collect_diff", "file": "diff.patch", "status": "accepted", "mode": "deterministic", "dependencies": ["run-full-tests"]}, + {"id": "collect-logs", "title": "collect_logs", "file": "evidence_manifest.json", "status": "accepted", "mode": "deterministic", "dependencies": ["collect-diff"]}, + ], + } + + +def dev_pipeline_parallel_project_defaults() -> dict[str, Any]: + return { + "id": PARALLEL_PIPELINE_PROJECT_ID, + "label": "Parallel Pipeline Project", + "surface": "Cento workset parallel execution", + "surface_value": PARALLEL_PIPELINE_TEMPLATE_ID, + "owned_root": "workspace/runs/parallel-pipeline/outputs", + "read_paths": [ + "AGENTS.md", + "README.md", + "scripts/**", + "templates/agent-work-app/**", + "docs/**", + "tests/**", + "data/tools.json", + ".cento/api_workers.yaml", + ], + } + + +def dev_pipeline_parallel_blueprint_defaults() -> dict[str, Any]: + return { + "id": PARALLEL_PIPELINE_TEMPLATE_ID, + "label": "Parallel workset pipeline", + "detail": "Contract-first parallel workers with one serialized integration lane", + "description": "Collects a parallel workset objective, exclusive write paths, runtime configuration, validation gates, and evidence policy, then runs independent workers concurrently and returns every patch through one sequential integration lane.", + "tagline": "Parallel owned-path delivery", + "slug": PARALLEL_PIPELINE_TEMPLATE_ID, + "worker_type": "parallel_workset_worker", + "execution_model": "parallel", + "worker_stage_label": "2. Parallel Work Config", + "factory_stage_label": "4. Parallel Workset Execution", + "validation_tier": "workset-contract", + "risk": "high", + "budget_spent_usd": 0.0, + "budget_cap_usd": 20.0, + "blueprint_version": "parallel-workset.v1", + "tasks_completed": 0, + "tasks_total": 7, + "selected_worker": "workset-config", + "max_parallel": 10, + "input_manifest": "inputs/parallel-pipeline_input_manifest.json", + "pipeline_config": "inputs/parallel-pipeline_pipeline_config.json", + "execution_manifest": "execution/parallel_execution_manifest.json", + "workers": [ + { + "id": "workset-config", + "title": "Workset Config Contract", + "file": "parallel_workset_config.json", + "description": "Normalize the objective, runtime limits, read context, and exclusive write-path contract before dispatch.", + "stage": "repo", + "manifest": "workers/parallel-pipeline_workset-config.json", + "integration_config": "integration/configs/parallel-workset-config.json", + "integration_receipt": "integration_receipts/parallel-pipeline_workset-config.json", + }, + { + "id": "parallel-split", + "title": "Parallel Worker Split", + "file": "parallel_worker_split.json", + "description": "Split independent owned-path workstreams into runnable workset tasks with explicit dependencies.", + "stage": "blueprint", + "dependencies": ["workset-config"], + "manifest": "workers/parallel-pipeline_parallel-split.json", + "integration_config": "integration/configs/parallel-split.json", + "integration_receipt": "integration_receipts/parallel-pipeline_parallel-split.json", + }, + { + "id": "serialized-integrator", + "title": "Serialized Integrator", + "file": "parallel_integrator.json", + "description": "Accept worker receipts one at a time, apply only non-overlapping patches, and preserve rollback evidence.", + "stage": "blueprint", + "dependencies": ["parallel-split"], + "manifest": "workers/parallel-pipeline_serialized-integrator.json", + "integration_config": "integration/configs/serialized-integrator.json", + "integration_receipt": "integration_receipts/parallel-pipeline_serialized-integrator.json", + }, + ], + "factory_steps": [ + {"id": "resolve-parallel-inputs", "title": "resolve_parallel_inputs", "file": "execution_run.json", "status": "accepted", "mode": "deterministic"}, + {"id": "write-parallel-workset", "title": "write_parallel_workset", "file": "workset.json", "status": "accepted", "mode": "deterministic", "dependencies": ["resolve-parallel-inputs"]}, + {"id": "dispatch-parallel-workers", "title": "dispatch_parallel_workers", "file": "workset_receipt.json", "status": "accepted", "mode": "api-openai-parallel", "dependencies": ["write-parallel-workset"]}, + {"id": "collect-worker-artifacts", "title": "collect_worker_artifacts", "file": "patch_bundles", "status": "accepted", "mode": "deterministic", "dependencies": ["dispatch-parallel-workers"]}, + {"id": "integrate-sequentially", "title": "integrate_sequentially", "file": "integration_receipts", "status": "accepted", "mode": "sequential", "dependencies": ["collect-worker-artifacts"]}, + {"id": "run-parallel-validation", "title": "run_parallel_validation", "file": "validation_receipts", "status": "accepted", "mode": "smoke", "dependencies": ["integrate-sequentially"]}, + {"id": "collect-parallel-evidence", "title": "collect_parallel_evidence", "file": "parallel_evidence.json", "status": "accepted", "mode": "deterministic", "dependencies": ["run-parallel-validation"]}, + ], + "validators": [ + {"id": "exclusive-paths", "title": "Exclusive Path Validator", "file": "exclusive_paths_receipt.json", "receipt": "validation/exclusive_paths_receipt.json", "config": "validation/validator_configs/exclusive-paths.json", "mode": "schema", "blocking": True, "status": "passed"}, + {"id": "workset-receipt", "title": "Workset Receipt Validator", "file": "workset_receipt_validator.json", "receipt": "validation/workset_receipt_validator.json", "config": "validation/validator_configs/workset-receipt.json", "mode": "evidence", "blocking": True, "status": "passed"}, + {"id": "serialized-integration", "title": "Serialized Integration Validator", "file": "serialized_integration_receipt.json", "receipt": "validation/serialized_integration_receipt.json", "config": "validation/validator_configs/serialized-integration.json", "mode": "commands", "blocking": True, "status": "passed"}, + ], + "evidence_artifacts": [ + {"id": "parallel-workset-manifest", "title": "Parallel Workset Manifest", "file": "workset.json", "status": "Configured", "kind": "artifact", "path": "execution/worksets/latest.json", "required_sources": ["parallel_worker_split.json"], "publish_policy": "Attach the runnable workset to every parallel pipeline handoff.", "retention_policy": "Keep with run artifacts."}, + {"id": "parallel-workset-receipt", "title": "Parallel Workset Receipt", "file": "workset_receipt.json", "status": "Configured", "kind": "receipt", "path": ".cento/worksets/*/workset_receipt.json", "required_sources": ["execution/worksets/*.json"], "publish_policy": "Use as proof that parallel workers converged through the serialized integration lane.", "retention_policy": "Keep with the workset run directory and pipeline evidence."}, + {"id": "parallel-handoff", "title": "Parallel Evidence Handoff", "file": "parallel_evidence.json", "status": "Configured", "kind": "bundle", "path": "evidence/parallel_evidence.json", "required_sources": ["workset_receipt.json", "validation_receipt.json"], "publish_policy": "Summarize changed paths, costs, worker outcomes, validators, and residual risks before review.", "retention_policy": "Keep with pipeline evidence."}, + ], + } + + +def dev_pipeline_patch_swarm_project_defaults() -> dict[str, Any]: + return { + "id": PATCH_SWARM_PROJECT_ID, + "label": "Patch Swarm Project", + "surface": "Cento patch swarm candidate market", + "surface_value": PATCH_SWARM_TEMPLATE_ID, + "owned_root": "workspace/runs/parallel-delivery/patch-swarm", + "read_paths": [ + "AGENTS.md", + "README.md", + "scripts/**", + "templates/agent-work-app/**", + "docs/**", + "tests/**", + "data/tools.json", + ".cento/runtimes.yaml", + ".cento/api_workers.yaml", + ], + } + + +def dev_pipeline_patch_swarm_blueprint_defaults() -> dict[str, Any]: + proreq_steps = [ + ("request-decomposer", "request_decomposer", "request_decomposer.json"), + ("codex-exec-adapter", "codex_exec_adapter", "codex_exec_adapter.json"), + ("claude-code-adapter", "claude_code_adapter", "claude_code_adapter.json"), + ("openai-patch-proposal-adapter", "openai_patch_proposal_adapter", "openai_patch_proposal_adapter.json"), + ("candidate-normalizer", "candidate_normalizer", "candidate_normalizer.json"), + ("dedupe-clustering", "dedupe_clustering", "dedupe_clustering.json"), + ("deterministic-validator-fanout", "deterministic_validator_fanout", "deterministic_validator_fanout.json"), + ("cost-latency-ledger", "cost_latency_ledger", "cost_latency_ledger.json"), + ("dev-pipeline-studio-ui", "dev_pipeline_studio_ui", "dev_pipeline_studio_ui.json"), + ("autopilot-coordinator-hooks", "autopilot_coordinator_hooks", "autopilot_coordinator_hooks.json"), + ] + return { + "id": PATCH_SWARM_TEMPLATE_ID, + "label": "Patch Swarm", + "detail": "100+ candidate patches, 5+ agents, one serialized integrator", + "description": "Runs ten ProReq execution lanes that can target Codex Exec, Claude Code, or OpenAI structured patch proposal workers, then feeds every candidate into one deterministic ranking and Safe Integrator handoff lane.", + "tagline": "Massively parallel patch candidate market", + "slug": PATCH_SWARM_TEMPLATE_ID, + "worker_type": "patch_swarm_candidate_worker", + "execution_model": "parallel", + "worker_stage_label": "2. ProReq Patch Lanes", + "factory_stage_label": "4. Candidate Swarm Execution", + "validation_tier": "patch-swarm-contract", + "risk": "high", + "budget_spent_usd": 0.0, + "budget_cap_usd": 20.0, + "blueprint_version": "patch-swarm.v1", + "tasks_completed": 0, + "tasks_total": 11, + "selected_worker": "request-decomposer", + "max_parallel": 5, + "input_manifest": "inputs/patch-swarm_input_manifest.json", + "pipeline_config": "inputs/patch-swarm_pipeline_config.json", + "execution_manifest": "execution/patch_swarm_execution_manifest.json", + "workers": [ + { + "id": step_id, + "title": title.replace("_", " ").title(), + "file": filename, + "description": "One of ten ProReq pipeline executions that produces provider-compatible candidate patch receipts.", + "stage": "blueprint", + "manifest": f"workers/patch-swarm_{step_id}.json", + "integration_config": f"integration/configs/patch-swarm-{step_id}.json", + "integration_receipt": f"integration_receipts/patch-swarm_{step_id}.json", + } + for step_id, title, filename in proreq_steps + ], + "factory_steps": [ + {"id": step_id, "title": title, "file": filename, "status": "queued", "mode": "proreq-patch-lane"} + for step_id, title, filename in proreq_steps + ] + + [ + { + "id": "dedicated-integrator", + "title": "dedicated_integrator", + "file": "integration_execution.json", + "status": "queued", + "mode": "serialized-safe-integrator-handoff", + "dependencies": [step_id for step_id, _title, _filename in proreq_steps], + } + ], + "validators": [ + {"id": "candidate-count", "title": "100+ Candidate Validator", "file": "candidate_count_receipt.json", "receipt": "validation/patch_swarm_candidate_count.json", "config": "validation/validator_configs/patch-swarm-candidate-count.json", "mode": "schema", "blocking": True, "status": "passed"}, + {"id": "provider-mix", "title": "Provider Mix Validator", "file": "provider_mix_receipt.json", "receipt": "validation/patch_swarm_provider_mix.json", "config": "validation/validator_configs/patch-swarm-provider-mix.json", "mode": "schema", "blocking": True, "status": "passed"}, + {"id": "dedicated-integrator", "title": "Dedicated Integrator Validator", "file": "dedicated_integrator_receipt.json", "receipt": "validation/patch_swarm_integrator.json", "config": "validation/validator_configs/patch-swarm-integrator.json", "mode": "evidence", "blocking": True, "status": "passed"}, + ], + "evidence_artifacts": [ + {"id": "patch-swarm-manifest", "title": "Patch Swarm Manifest", "file": "patch_swarm_manifest.json", "status": "Configured", "kind": "artifact", "path": "execution/patch-swarm/latest/patch_swarm_manifest.json", "required_sources": ["inputs/patch-swarm_input_manifest.json"], "publish_policy": "Attach the run manifest before candidate dispatch.", "retention_policy": "Keep with patch swarm run artifacts."}, + {"id": "candidate-index", "title": "Candidate Index", "file": "candidate_index.json", "status": "Configured", "kind": "artifact", "path": "execution/patch-swarm/latest/candidate_index.json", "required_sources": ["patch_swarm_manifest.json"], "publish_policy": "Use as the source of truth for candidate receipts, providers, costs, and validation state.", "retention_policy": "Keep with patch swarm run artifacts."}, + {"id": "safe-integrator-handoff", "title": "Safe Integrator Handoff", "file": "safe_integrator_handoff.json", "status": "Configured", "kind": "bundle", "path": "execution/patch-swarm/latest/safe_integrator_handoff.json", "required_sources": ["candidate_index.json", "ranking.json", "integration_execution.json"], "publish_policy": "Publish only after the dedicated integrator selects winners.", "retention_policy": "Keep with patch swarm run artifacts."}, + ], + } + + +def dev_pipeline_hard_proreq_project_defaults() -> dict[str, Any]: + return { + "id": HARD_PROREQ_PROJECT_ID, + "label": "Hard Proreq Project", + "surface": "Cento pro requirements route", + "surface_value": HARD_PROREQ_TEMPLATE_ID, + "owned_root": "workspace/runs/hard-proreq/outputs", + "read_paths": [ + "AGENTS.md", + "README.md", + "scripts/**", + "templates/agent-work-app/**", + "docs/**", + "tests/**", + "data/tools.json", + ".cento/api_workers.yaml", + ], + } + + +def dev_pipeline_hard_proreq_schema() -> dict[str, Any]: + text = {"type": "string"} + text_array = {"type": "array", "items": {"type": "string"}} + workstream = { + "type": "object", + "properties": { + "id": text, + "title": text, + "intent": text, + "owned_paths": text_array, + "read_paths": text_array, + "depends_on": text_array, + "validation_commands": text_array, + "handoff_artifacts": text_array, + }, + "required": ["id", "title", "intent", "owned_paths", "read_paths", "depends_on", "validation_commands", "handoff_artifacts"], + "additionalProperties": False, + } + return { + "type": "object", + "properties": { + "schema_version": {"type": "string", "enum": ["cento.hard_proreq_backend_plan.v1"]}, + "summary": text, + "backend_workstreams": {"type": "array", "items": workstream}, + "integration_plan": text_array, + "validation_plan": text_array, + "parallelization_notes": text_array, + "codex_exec_prompts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": text, + "prompt": text, + "output_schema": text, + }, + "required": ["id", "prompt", "output_schema"], + "additionalProperties": False, + }, + }, + "risks": text_array, + }, + "required": [ + "schema_version", + "summary", + "backend_workstreams", + "integration_plan", + "validation_plan", + "parallelization_notes", + "codex_exec_prompts", + "risks", + ], + "additionalProperties": False, + } + + +def dev_pipeline_hard_proreq_blueprint_defaults() -> dict[str, Any]: + return { + "id": HARD_PROREQ_TEMPLATE_ID, + "label": "Hard proreq task", + "detail": "Manifest-backed requirement planning with optional screenshot context", + "description": "Transforms operator thoughts, optional screenshot context, and questionnaire answers into Cento context, ten story manifests, parallel patch workset handoff, manifest-driven integration, validation, and evidence.", + "tagline": "Default hard prompt route", + "slug": HARD_PROREQ_TEMPLATE_ID, + "worker_type": "hard_proreq_worker", + "execution_model": "ordered", + "worker_stage_label": "2. Cento Context", + "factory_stage_label": "4. Proreq Planning", + "validation_tier": "proreq-contract", + "risk": "high", + "budget_spent_usd": 0.0, + "budget_cap_usd": 20.0, + "blueprint_version": "hard-proreq.v1", + "tasks_completed": 0, + "tasks_total": 10, + "selected_worker": "mini-cento-context", + "input_manifest": "inputs/hard-proreq-task_input_manifest.json", + "pipeline_config": "inputs/hard-proreq-task_pipeline_config.json", + "execution_manifest": "execution/execution_manifest.json", + "workers": [ + { + "id": "mini-cento-context", + "title": "Mini Cento Context", + "file": "mini_cento_context.json", + "description": "Use Cento-native context gathering and repo search to summarize the task surface before pro planning.", + "stage": "repo", + "manifest": "workers/hard-proreq-task_mini-cento-context.json", + "integration_config": "integration/configs/mini-cento-context.json", + "integration_receipt": "integration_receipts/hard-proreq-task_mini-cento-context.json", + }, + { + "id": "proreq-splitter", + "title": "Prompt Splitter", + "file": "proreq_prompt_split.json", + "description": "Split operator input into optional muted UI screenshot context, ten story manifests, and a schema-backed backend planning request.", + "stage": "blueprint", + "dependencies": ["mini-cento-context"], + "manifest": "workers/hard-proreq-task_proreq-splitter.json", + "integration_config": "integration/configs/proreq-splitter.json", + "integration_receipt": "integration_receipts/hard-proreq-task_proreq-splitter.json", + }, + { + "id": "backend-work-materializer", + "title": "Backend Work Materializer", + "file": "backend_work_manifest.json", + "description": "Turn GPT pro backend plan output into Cento-native backend work prompts and validation gates.", + "stage": "blueprint", + "dependencies": ["proreq-splitter"], + "manifest": "workers/hard-proreq-task_backend-work-materializer.json", + "integration_config": "integration/configs/backend-work-materializer.json", + "integration_receipt": "integration_receipts/hard-proreq-task_backend-work-materializer.json", + }, + ], + "factory_steps": [ + {"id": "collect-operator-intake", "title": "collect_operator_intake", "file": "operator_intake.json", "status": "accepted", "mode": "deterministic"}, + {"id": "build-cento-context", "title": "build_mini_cento_context", "file": "mini_cento_context.json", "status": "accepted", "mode": "deterministic", "dependencies": ["collect-operator-intake"]}, + {"id": "write-ui-screenshot-request", "title": "ui_screenshot_request_muted", "file": "ui_screenshot_request.json", "status": "muted", "mode": "frontend-separate", "muted": True, "lane": "frontend", "dependencies": ["build-cento-context"]}, + {"id": "prepare-pro-backend-request", "title": "prepare_gpt_pro_backend_request", "file": "pro_backend_request.json", "status": "accepted", "mode": "structured-output", "dependencies": ["build-cento-context"]}, + {"id": "dispatch-pro-backend-plan", "title": "gpt_pro_backend_plan", "file": "pro_backend_plan.json", "status": "accepted", "mode": "api-openai-pro", "dependencies": ["prepare-pro-backend-request"]}, + {"id": "materialize-backend-work", "title": "materialize_10_story_backend_work", "file": "backend_work_manifest.json", "status": "accepted", "mode": "cento-native", "dependencies": ["dispatch-pro-backend-plan"]}, + {"id": "write-integration-plan", "title": "write_integration_plan", "file": "integration_plan.json", "status": "accepted", "mode": "deterministic", "dependencies": ["materialize-backend-work"]}, + {"id": "write-validation-plan", "title": "write_validation_plan", "file": "validation_plan.json", "status": "accepted", "mode": "deterministic", "dependencies": ["write-integration-plan"]}, + {"id": "collect-proreq-evidence", "title": "collect_proreq_evidence", "file": "hard_proreq_evidence.json", "status": "accepted", "mode": "deterministic", "dependencies": ["write-validation-plan"]}, + ], + "validators": [ + {"id": "schema", "title": "Schema Validator", "file": "schema_receipt.json", "receipt": "validation/schema_receipt.json", "config": "validation/validator_configs/schema.json", "mode": "schema", "blocking": True, "status": "passed"}, + {"id": "proreq-contract", "title": "Proreq Contract Validator", "file": "proreq_contract_receipt.json", "receipt": "validation/proreq_contract_receipt.json", "config": "validation/validator_configs/proreq-contract.json", "mode": "schema", "blocking": True, "status": "passed"}, + {"id": "frontend-muted", "title": "Muted Frontend Flow Validator", "file": "frontend_muted_receipt.json", "receipt": "validation/frontend_muted_receipt.json", "config": "validation/validator_configs/frontend-muted.json", "mode": "evidence", "blocking": False, "status": "muted"}, + ], + "evidence_artifacts": [ + {"id": "pro-backend-request", "title": "GPT Pro Backend Request", "file": "pro_backend_request.json", "status": "Configured", "kind": "artifact", "path": "execution/hard-proreq/latest/pro_backend_request.json", "required_sources": ["operator_intake.json", "mini_cento_context.json", "pro_output_schema.json"], "publish_policy": "Attach the schema-backed request to every hard proreq handoff.", "retention_policy": "Keep with run artifacts."}, + {"id": "backend-work-manifest", "title": "Cento Backend Work Manifest", "file": "backend_work_manifest.json", "status": "Configured", "kind": "artifact", "path": "execution/hard-proreq/latest/backend_work_manifest.json", "required_sources": ["pro_backend_plan.json"], "publish_policy": "Use as the Codex/Cento backend work launcher input for ten story manifests and the parallel patch workset.", "retention_policy": "Keep with run artifacts."}, + ], + } + + +def dev_pipeline_proreq_light_project_defaults() -> dict[str, Any]: + project = deepcopy(dev_pipeline_hard_proreq_project_defaults()) + project.update( + { + "id": PROREQ_LIGHT_PROJECT_ID, + "label": "ProReq Light Project", + "surface": "Cento Codex Exec requirements route", + "surface_value": PROREQ_LIGHT_TEMPLATE_ID, + "owned_root": "workspace/runs/proreq-light/outputs", + } + ) + return project + + +def dev_pipeline_proreq_light_blueprint_defaults() -> dict[str, Any]: + template = deepcopy(dev_pipeline_hard_proreq_blueprint_defaults()) + template.update( + { + "id": PROREQ_LIGHT_TEMPLATE_ID, + "label": "ProReq light task", + "detail": "Codex Exec requirement planning with optional screenshot context", + "description": "Transforms operator thoughts, optional screenshot context, and Cento context into the same ten-story ProReq artifacts, but replaces the live ChatGPT Pro API call with a Codex Exec prompt that simulates the Pro planning lane.", + "tagline": "Codex Exec ProReq route", + "slug": PROREQ_LIGHT_TEMPLATE_ID, + "worker_type": "proreq_light_codex_worker", + "factory_stage_label": "4. ProReq Light Planning", + "validation_tier": "proreq-light-contract", + "risk": "medium", + "budget_spent_usd": 0.0, + "budget_cap_usd": 0.0, + "blueprint_version": "proreq-light.v1", + "selected_worker": "mini-cento-context", + } + ) + for worker in template.get("workers", []): + if not isinstance(worker, dict): + continue + if worker.get("id") == "proreq-splitter": + worker["title"] = "Codex Pro Prompt Splitter" + worker["description"] = "Prepare the strict schema and Codex Exec prompt that starts with \"You're chatGPT Pro model\" instead of dispatching a live Pro API call." + elif worker.get("id") == "backend-work-materializer": + worker["description"] = "Turn the Codex Exec ProReq-light plan into Cento-native story manifests and validation gates." + for step in template.get("factory_steps", []): + if not isinstance(step, dict): + continue + if step.get("id") == "prepare-pro-backend-request": + step["title"] = "prepare_codex_pro_backend_request" + step["mode"] = "codex-exec-request" + elif step.get("id") == "dispatch-pro-backend-plan": + step["id"] = "dispatch-codex-pro-backend-plan" + step["title"] = "codex_exec_pro_backend_plan" + step["mode"] = "codex-exec-proreq-light" + for artifact in template.get("evidence_artifacts", []): + if not isinstance(artifact, dict): + continue + if artifact.get("id") == "pro-backend-request": + artifact["title"] = "Codex Exec ProReq Prompt" + artifact["file"] = "proreq_light_codex_prompt.md" + artifact["path"] = "execution/hard-proreq/latest/proreq_light_codex_prompt.md" + artifact["required_sources"] = ["operator_intake.json", "mini_cento_context.json", "pro_output_schema.json", "pro_backend_request.json"] + artifact["publish_policy"] = "Attach the Codex Exec prompt and command receipt to every ProReq-light handoff." + elif artifact.get("id") == "backend-work-manifest": + artifact["required_sources"] = ["pro_backend_plan.json", "proreq_light_codex_response.json"] + template.setdefault("evidence_artifacts", []).append( + { + "id": "codex-proreq-light-response", + "title": "Codex Exec ProReq Response", + "file": "proreq_light_codex_response.json", + "status": "Configured", + "kind": "artifact", + "path": "execution/hard-proreq/latest/proreq_light_codex_response.json", + "required_sources": ["proreq_light_codex_prompt.md", "proreq_light_output_schema.json", "proreq_light_codex_command.json"], + "publish_policy": "Preserve Codex Exec stdout/stderr and fallback status for every light run.", + "retention_policy": "Keep with run artifacts.", + } + ) + return template + + +def dev_pipeline_multipipeline_project_defaults() -> dict[str, Any]: + return { + "id": MULTIPIPELINE_PROJECT_ID, + "label": "Multipipeline ProReq Project", + "surface": "Sequential ProReq meta-pipeline", + "surface_value": MULTIPIPELINE_TEMPLATE_ID, + "owned_root": "workspace/runs/multipipeline-proreq/outputs", + "read_paths": [ + "AGENTS.md", + "README.md", + "scripts/**", + "templates/agent-work-app/**", + "docs/**", + "tests/**", + "data/tools.json", + ".cento/api_workers.yaml", + ], + } + + +def dev_pipeline_multipipeline_blueprint_defaults() -> dict[str, Any]: + return { + "id": MULTIPIPELINE_TEMPLATE_ID, + "label": "Multipipeline ProReq chain", + "detail": "Four sequential ProReq passes where each pass feeds guidance to the next", + "description": "Schedules four ordered ProReq request passes for an operator-defined multipipeline objective. Each pass consumes the previous pass guidance, writes the next ProReq request, preserves UI screenshot guidance, prepares a ChatGPT Pro request, and emits validation-ready evidence.", + "tagline": "Sequential ProReq coordinator", + "slug": MULTIPIPELINE_TEMPLATE_ID, + "worker_type": "multipipeline_proreq_coordinator", + "execution_model": "ordered", + "worker_stage_label": "2. Multipipeline Context", + "factory_stage_label": "4. Sequential ProReq Passes", + "validation_tier": "multipipeline-contract", + "risk": "medium", + "budget_spent_usd": 0.0, + "budget_cap_usd": 0.0, + "blueprint_version": "multipipeline-proreq-chain.v1", + "tasks_completed": 0, + "tasks_total": 9, + "selected_worker": "chain-scheduler", + "input_manifest": "inputs/multipipeline-proreq-chain_input_manifest.json", + "pipeline_config": "inputs/multipipeline-proreq-chain_pipeline_config.json", + "execution_manifest": "execution/multipipeline_execution_manifest.json", + "workers": [ + { + "id": "chain-intake", + "title": "Meta-pipeline Intake", + "file": "operator_intake.json", + "description": "Normalize the operator objective, improvement boundaries, and compute policy before scheduling child ProReq passes.", + "stage": "repo", + "manifest": "workers/multipipeline-proreq-chain_chain-intake.json", + "integration_config": "integration/configs/multipipeline-chain-intake.json", + "integration_receipt": "integration_receipts/multipipeline-proreq-chain_chain-intake.json", + }, + { + "id": "chain-scheduler", + "title": "Sequential ProReq Scheduler", + "file": "multipipeline_schedule.json", + "description": "Create four ordered ProReq pass requests, each dependent on the previous pass guidance artifact.", + "stage": "blueprint", + "dependencies": ["chain-intake"], + "manifest": "workers/multipipeline-proreq-chain_chain-scheduler.json", + "integration_config": "integration/configs/multipipeline-chain-scheduler.json", + "integration_receipt": "integration_receipts/multipipeline-proreq-chain_chain-scheduler.json", + }, + { + "id": "chain-handoff", + "title": "Guidance And Evidence Handoff", + "file": "multipipeline_evidence.json", + "description": "Collect pass receipts, UI screenshot prompt, ChatGPT Pro request, roadmap, and validation handoff evidence.", + "stage": "blueprint", + "dependencies": ["chain-scheduler"], + "manifest": "workers/multipipeline-proreq-chain_chain-handoff.json", + "integration_config": "integration/configs/multipipeline-chain-handoff.json", + "integration_receipt": "integration_receipts/multipipeline-proreq-chain_chain-handoff.json", + }, + ], + "factory_steps": [ + {"id": "collect-multipipeline-intake", "title": "collect_multipipeline_intake", "file": "operator_intake.json", "status": "accepted", "mode": "deterministic"}, + {"id": "write-multipipeline-schedule", "title": "write_multipipeline_schedule", "file": "multipipeline_schedule.json", "status": "accepted", "mode": "deterministic", "dependencies": ["collect-multipipeline-intake"]}, + {"id": "run-proreq-pass-1", "title": "proreq_pass_1_scope", "file": "pass_01_proreq_request.json", "status": "accepted", "mode": "request-artifact", "dependencies": ["write-multipipeline-schedule"]}, + {"id": "run-proreq-pass-2", "title": "proreq_pass_2_architecture", "file": "pass_02_proreq_request.json", "status": "accepted", "mode": "request-artifact", "dependencies": ["run-proreq-pass-1"]}, + {"id": "run-proreq-pass-3", "title": "proreq_pass_3_integration", "file": "pass_03_proreq_request.json", "status": "accepted", "mode": "request-artifact", "dependencies": ["run-proreq-pass-2"]}, + {"id": "run-proreq-pass-4", "title": "proreq_pass_4_validation", "file": "pass_04_proreq_request.json", "status": "accepted", "mode": "request-artifact", "dependencies": ["run-proreq-pass-3"]}, + {"id": "write-multipipeline-ui-screenshot-request", "title": "write_ui_screenshot_request", "file": "ui_screenshot_request.json", "status": "muted", "mode": "frontend-separate", "muted": True, "lane": "frontend", "dependencies": ["run-proreq-pass-4"]}, + {"id": "write-multipipeline-pro-request", "title": "write_chatgpt_pro_request", "file": "chatgpt_pro_request.json", "status": "accepted", "mode": "structured-output", "dependencies": ["write-multipipeline-ui-screenshot-request"]}, + {"id": "collect-multipipeline-evidence", "title": "collect_multipipeline_evidence", "file": "multipipeline_evidence.json", "status": "accepted", "mode": "deterministic", "dependencies": ["write-multipipeline-pro-request"]}, + ], + "validators": [ + {"id": "sequential-schedule", "title": "Sequential Schedule Validator", "file": "sequential_schedule_receipt.json", "receipt": "validation/sequential_schedule_receipt.json", "config": "validation/validator_configs/sequential-schedule.json", "mode": "schema", "blocking": True, "status": "passed"}, + {"id": "proreq-pass-handoff", "title": "ProReq Pass Handoff Validator", "file": "proreq_pass_handoff_receipt.json", "receipt": "validation/proreq_pass_handoff_receipt.json", "config": "validation/validator_configs/proreq-pass-handoff.json", "mode": "evidence", "blocking": True, "status": "passed"}, + {"id": "ui-pro-request", "title": "UI And Pro Request Validator", "file": "ui_pro_request_receipt.json", "receipt": "validation/ui_pro_request_receipt.json", "config": "validation/validator_configs/ui-pro-request.json", "mode": "evidence", "blocking": False, "status": "passed"}, + ], + "evidence_artifacts": [ + {"id": "multipipeline-schedule", "title": "Multipipeline Schedule", "file": "multipipeline_schedule.json", "status": "Configured", "kind": "artifact", "path": "execution/multipipeline/latest/multipipeline_schedule.json", "required_sources": ["operator_intake.json"], "publish_policy": "Attach to every meta-pipeline handoff before sequential ProReq pass execution.", "retention_policy": "Keep with run artifacts."}, + {"id": "multipipeline-pass-guidance", "title": "Sequential Pass Guidance", "file": "pass_04_guidance.json", "status": "Configured", "kind": "artifact", "path": "execution/multipipeline/latest/pass_04_guidance.json", "required_sources": ["pass_01_guidance.json", "pass_02_guidance.json", "pass_03_guidance.json"], "publish_policy": "Use the final pass guidance as the next operator prompt or implementation request.", "retention_policy": "Keep with run artifacts."}, + {"id": "multipipeline-evidence", "title": "Meta-pipeline Evidence", "file": "multipipeline_evidence.json", "status": "Configured", "kind": "bundle", "path": "execution/multipipeline/latest/multipipeline_evidence.json", "required_sources": ["multipipeline_schedule.json", "ui_screenshot_request.json", "chatgpt_pro_request.json"], "publish_policy": "Publish after all four pass request artifacts and validation handoff are present.", "retention_policy": "Keep with run artifacts."}, + ], + } + + +def dev_pipeline_merge_default_fields(current: dict[str, Any], default: dict[str, Any]) -> dict[str, Any]: + merged = deepcopy(current) + for key, value in default.items(): + if key not in merged or merged.get(key) is None: + merged[key] = deepcopy(value) + return merged + + +def dev_pipeline_migrate_run_pipeline_wording(item: dict[str, Any]) -> dict[str, Any]: + for key in ("title", "detail", "evidence_policy", "answer_notes"): + if isinstance(item.get(key), str): + item[key] = str(item[key]).replace("New Issue", "Run Pipeline").replace("New issue", "Run pipeline") + return item + + +def dev_pipeline_merge_builtin_item(current: dict[str, Any], default: dict[str, Any], forced_keys: tuple[str, ...]) -> dict[str, Any]: + merged = dev_pipeline_merge_default_fields(current, default) + for key in forced_keys: + if key in default: + merged[key] = deepcopy(default[key]) + return merged + + +def dev_pipeline_ensure_builtin_pipelines(manifest: dict[str, Any]) -> bool: + changed = False + projects = manifest.get("projects") + if not isinstance(projects, list): + projects = [] + manifest["projects"] = projects + changed = True + project_defaults = [ + dev_pipeline_hard_proreq_project_defaults(), + dev_pipeline_proreq_light_project_defaults(), + dev_pipeline_multipipeline_project_defaults(), + dev_pipeline_parallel_project_defaults(), + dev_pipeline_patch_swarm_project_defaults(), + ] + for index, default_project in enumerate(project_defaults): + project_id = str(default_project.get("id") or "") + if not any(isinstance(item, dict) and str(item.get("id") or "") == project_id for item in projects): + projects.insert(min(index, len(projects)), default_project) + changed = True + + templates = manifest.get("templates") + if not isinstance(templates, list): + templates = [] + manifest["templates"] = templates + changed = True + template_defaults = [ + dev_pipeline_hard_proreq_blueprint_defaults(), + dev_pipeline_proreq_light_blueprint_defaults(), + dev_pipeline_multipipeline_blueprint_defaults(), + dev_pipeline_parallel_blueprint_defaults(), + dev_pipeline_patch_swarm_blueprint_defaults(), + ] + for index, default_template in enumerate(template_defaults): + template_id = str(default_template.get("id") or "") + if not any(isinstance(item, dict) and str(item.get("id") or "") == template_id for item in templates): + templates.insert(min(index, len(templates)), default_template) + changed = True + + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + project_ids = {str(item.get("id") or "") for item in projects if isinstance(item, dict)} + template_ids = {str(item.get("id") or "") for item in templates if isinstance(item, dict)} + if str(defaults.get("project_id") or "") not in project_ids: + defaults["project_id"] = DEFAULT_DEV_PIPELINE_PROJECT_ID + changed = True + if str(defaults.get("template_id") or "") not in template_ids: + defaults["template_id"] = DEFAULT_DEV_PIPELINE_TEMPLATE_ID + changed = True + if changed: + manifest["defaults"] = defaults + return changed + + +def dev_pipeline_apply_generic_blueprint(template: dict[str, Any]) -> dict[str, Any]: + if str(template.get("id") or "") in {HARD_PROREQ_TEMPLATE_ID, PROREQ_LIGHT_TEMPLATE_ID}: + defaults = dev_pipeline_proreq_light_blueprint_defaults() if str(template.get("id") or "") == PROREQ_LIGHT_TEMPLATE_ID else dev_pipeline_hard_proreq_blueprint_defaults() + for key in ("label", "detail", "description", "tagline", "worker_type", "execution_model", "worker_stage_label", "factory_stage_label", "blueprint_version", "tasks_completed", "tasks_total", "validation_tier", "risk", "budget_cap_usd", "input_manifest", "pipeline_config", "execution_manifest"): + if key not in template or template.get(key) is None or (isinstance(template.get(key), str) and not str(template.get(key)).strip()): + template[key] = deepcopy(defaults[key]) + for key in ("detail", "description", "factory_stage_label", "tasks_total", "budget_cap_usd", "blueprint_version"): + template[key] = deepcopy(defaults[key]) + for list_key in ("workers", "factory_steps", "validators", "evidence_artifacts"): + default_items = {str(item.get("id") or ""): item for item in defaults.get(list_key, []) if isinstance(item, dict)} + raw_items = template.get(list_key) + forced_keys = { + "workers": ("title", "file", "description", "stage", "dependencies", "manifest", "integration_config", "integration_receipt"), + "factory_steps": ("title", "file", "mode", "muted", "lane", "dependencies"), + "validators": ("title", "file", "receipt", "config", "mode", "blocking"), + "evidence_artifacts": ("title", "file", "kind", "path", "required_sources", "publish_policy", "retention_policy"), + }.get(list_key, ()) + if isinstance(raw_items, list): + seen: set[str] = set() + merged_items = [] + for item in raw_items: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + seen.add(item_id) + merged_items.append(dev_pipeline_merge_builtin_item(item, default_items.get(item_id, {}), forced_keys)) + for item_id, default_item in default_items.items(): + if item_id not in seen: + merged_items.append(deepcopy(default_item)) + template[list_key] = merged_items + else: + template[list_key] = deepcopy(defaults[list_key]) + default_inputs = dev_pipeline_default_required_inputs(str(template.get("id") or HARD_PROREQ_TEMPLATE_ID)) + default_input_map = {str(item.get("id") or ""): item for item in default_inputs if isinstance(item, dict)} + raw_inputs = template.get("required_inputs") + if isinstance(raw_inputs, list): + seen_inputs: set[str] = set() + merged_inputs = [] + forced_input_keys = ( + "title", + "detail", + "kind", + "source", + "automation", + "format", + "questions", + "paths", + "path_policy", + "artifacts", + "image_refs", + "image_notes", + "evidence_policy", + "required", + "muted", + "blocking", + ) + for item in raw_inputs: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + seen_inputs.add(item_id) + merged_inputs.append(dev_pipeline_migrate_run_pipeline_wording(dev_pipeline_merge_builtin_item(item, default_input_map.get(item_id, {}), forced_input_keys))) + for item_id, default_item in default_input_map.items(): + if item_id not in seen_inputs: + merged_inputs.append(dev_pipeline_migrate_run_pipeline_wording(deepcopy(default_item))) + template["required_inputs"] = merged_inputs + else: + template["required_inputs"] = [dev_pipeline_migrate_run_pipeline_wording(item) for item in default_inputs] + screenshot_defaults = default_input_map.get("ui-screenshot-request", {}) + for item in template.get("required_inputs", []): + if not isinstance(item, dict) or str(item.get("id") or "") != "ui-screenshot-request": + continue + item["image_refs"] = list( + dict.fromkeys( + dev_pipeline_text_list(item.get("image_refs"), []) + + dev_pipeline_text_list(screenshot_defaults.get("image_refs"), []) + ) + ) + item["artifacts"] = list( + dict.fromkeys( + dev_pipeline_text_list(item.get("artifacts"), []) + + dev_pipeline_text_list(screenshot_defaults.get("artifacts"), []) + ) + ) + workers = [worker for worker in template.get("workers", []) if isinstance(worker, dict)] + if not any(str(worker.get("id") or "") == str(template.get("selected_worker") or "") for worker in workers): + template["selected_worker"] = str(workers[0].get("id") or "") if workers else "" + return template + + if str(template.get("id") or "") == MULTIPIPELINE_TEMPLATE_ID: + defaults = dev_pipeline_multipipeline_blueprint_defaults() + for key in ("label", "detail", "description", "tagline", "worker_type", "execution_model", "worker_stage_label", "factory_stage_label", "blueprint_version", "tasks_completed", "tasks_total", "validation_tier", "risk", "budget_cap_usd", "input_manifest", "pipeline_config", "execution_manifest"): + if key not in template or template.get(key) is None or (isinstance(template.get(key), str) and not str(template.get(key)).strip()): + template[key] = deepcopy(defaults[key]) + for key in ("detail", "description", "factory_stage_label", "tasks_total", "budget_cap_usd", "blueprint_version"): + template[key] = deepcopy(defaults[key]) + for list_key in ("workers", "factory_steps", "validators", "evidence_artifacts"): + default_items = {str(item.get("id") or ""): item for item in defaults.get(list_key, []) if isinstance(item, dict)} + raw_items = template.get(list_key) + forced_keys = { + "workers": ("title", "file", "description", "stage", "dependencies", "manifest", "integration_config", "integration_receipt"), + "factory_steps": ("title", "file", "mode", "muted", "lane", "dependencies"), + "validators": ("title", "file", "receipt", "config", "mode", "blocking"), + "evidence_artifacts": ("title", "file", "kind", "path", "required_sources", "publish_policy", "retention_policy"), + }.get(list_key, ()) + if isinstance(raw_items, list): + seen: set[str] = set() + merged_items = [] + for item in raw_items: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + seen.add(item_id) + merged_items.append(dev_pipeline_merge_builtin_item(item, default_items.get(item_id, {}), forced_keys)) + for item_id, default_item in default_items.items(): + if item_id not in seen: + merged_items.append(deepcopy(default_item)) + template[list_key] = merged_items + else: + template[list_key] = deepcopy(defaults[list_key]) + default_inputs = dev_pipeline_default_required_inputs(MULTIPIPELINE_TEMPLATE_ID) + default_input_map = {str(item.get("id") or ""): item for item in default_inputs if isinstance(item, dict)} + raw_inputs = template.get("required_inputs") + if isinstance(raw_inputs, list): + seen_inputs: set[str] = set() + merged_inputs = [] + forced_input_keys = ( + "title", + "detail", + "kind", + "source", + "automation", + "format", + "questions", + "paths", + "path_policy", + "artifacts", + "image_refs", + "image_notes", + "evidence_policy", + "required", + "muted", + "blocking", + "answer", + ) + for item in raw_inputs: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + seen_inputs.add(item_id) + merged_inputs.append(dev_pipeline_migrate_run_pipeline_wording(dev_pipeline_merge_builtin_item(item, default_input_map.get(item_id, {}), forced_input_keys))) + for item_id, default_item in default_input_map.items(): + if item_id not in seen_inputs: + merged_inputs.append(dev_pipeline_migrate_run_pipeline_wording(deepcopy(default_item))) + template["required_inputs"] = merged_inputs + else: + template["required_inputs"] = [dev_pipeline_migrate_run_pipeline_wording(item) for item in default_inputs] + workers = [worker for worker in template.get("workers", []) if isinstance(worker, dict)] + if not any(str(worker.get("id") or "") == str(template.get("selected_worker") or "") for worker in workers): + template["selected_worker"] = str(workers[0].get("id") or "") if workers else "" + return template + + if str(template.get("id") or "") == PARALLEL_PIPELINE_TEMPLATE_ID: + defaults = dev_pipeline_parallel_blueprint_defaults() + for key in ("label", "detail", "description", "tagline", "worker_type", "execution_model", "worker_stage_label", "factory_stage_label", "blueprint_version", "tasks_completed", "tasks_total", "validation_tier", "risk", "budget_cap_usd", "input_manifest", "pipeline_config", "execution_manifest", "max_parallel"): + if key not in template or template.get(key) is None or (isinstance(template.get(key), str) and not str(template.get(key)).strip()): + template[key] = deepcopy(defaults[key]) + for key in ("detail", "description", "tasks_total", "budget_cap_usd", "max_parallel", "blueprint_version"): + template[key] = deepcopy(defaults[key]) + for list_key in ("workers", "factory_steps", "validators", "evidence_artifacts"): + default_items = {str(item.get("id") or ""): item for item in defaults.get(list_key, []) if isinstance(item, dict)} + raw_items = template.get(list_key) + if isinstance(raw_items, list): + seen: set[str] = set() + merged_items = [] + for item in raw_items: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + seen.add(item_id) + merged_items.append(dev_pipeline_merge_default_fields(item, default_items.get(item_id, {}))) + for item_id, default_item in default_items.items(): + if item_id not in seen: + merged_items.append(deepcopy(default_item)) + template[list_key] = merged_items + else: + template[list_key] = deepcopy(defaults[list_key]) + default_inputs = dev_pipeline_default_required_inputs(PARALLEL_PIPELINE_TEMPLATE_ID) + default_input_map = {str(item.get("id") or ""): item for item in default_inputs if isinstance(item, dict)} + raw_inputs = template.get("required_inputs") + if isinstance(raw_inputs, list): + seen_inputs: set[str] = set() + merged_inputs = [] + forced_input_keys = ( + "title", + "detail", + "kind", + "source", + "automation", + "format", + "questions", + "paths", + "path_policy", + "artifacts", + "evidence_policy", + "required", + "advanced", + "status", + "answer", + ) + for item in raw_inputs: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + seen_inputs.add(item_id) + merged_inputs.append(dev_pipeline_merge_builtin_item(item, default_input_map.get(item_id, {}), forced_input_keys)) + for item_id, default_item in default_input_map.items(): + if item_id not in seen_inputs: + merged_inputs.append(deepcopy(default_item)) + template["required_inputs"] = merged_inputs + else: + template["required_inputs"] = default_inputs + workers = [worker for worker in template.get("workers", []) if isinstance(worker, dict)] + if not any(str(worker.get("id") or "") == str(template.get("selected_worker") or "") for worker in workers): + template["selected_worker"] = str(workers[0].get("id") or "") if workers else "" + return template + + if str(template.get("id") or "") == PATCH_SWARM_TEMPLATE_ID: + defaults = dev_pipeline_patch_swarm_blueprint_defaults() + for key in ("label", "detail", "description", "tagline", "worker_type", "execution_model", "worker_stage_label", "factory_stage_label", "blueprint_version", "tasks_completed", "tasks_total", "validation_tier", "risk", "budget_cap_usd", "input_manifest", "pipeline_config", "execution_manifest", "max_parallel"): + if key not in template or template.get(key) is None or (isinstance(template.get(key), str) and not str(template.get(key)).strip()): + template[key] = deepcopy(defaults[key]) + for key in ("detail", "description", "tasks_total", "budget_cap_usd", "max_parallel", "blueprint_version"): + template[key] = deepcopy(defaults[key]) + for list_key in ("workers", "factory_steps", "validators", "evidence_artifacts"): + default_items = {str(item.get("id") or ""): item for item in defaults.get(list_key, []) if isinstance(item, dict)} + raw_items = template.get(list_key) + if isinstance(raw_items, list): + seen: set[str] = set() + merged_items = [] + for item in raw_items: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + seen.add(item_id) + merged_items.append(dev_pipeline_merge_default_fields(item, default_items.get(item_id, {}))) + for item_id, default_item in default_items.items(): + if item_id not in seen: + merged_items.append(deepcopy(default_item)) + template[list_key] = merged_items + else: + template[list_key] = deepcopy(defaults[list_key]) + default_inputs = dev_pipeline_default_required_inputs(PATCH_SWARM_TEMPLATE_ID) + default_input_map = {str(item.get("id") or ""): item for item in default_inputs if isinstance(item, dict)} + raw_inputs = template.get("required_inputs") + if isinstance(raw_inputs, list): + seen_inputs: set[str] = set() + merged_inputs = [] + forced_input_keys = ( + "title", + "detail", + "kind", + "source", + "automation", + "format", + "questions", + "paths", + "path_policy", + "artifacts", + "evidence_policy", + "required", + "advanced", + "status", + "answer", + ) + for item in raw_inputs: + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + seen_inputs.add(item_id) + merged_inputs.append(dev_pipeline_merge_builtin_item(item, default_input_map.get(item_id, {}), forced_input_keys)) + for item_id, default_item in default_input_map.items(): + if item_id not in seen_inputs: + merged_inputs.append(deepcopy(default_item)) + template["required_inputs"] = merged_inputs + else: + template["required_inputs"] = default_inputs + workers = [worker for worker in template.get("workers", []) if isinstance(worker, dict)] + if not any(str(worker.get("id") or "") == str(template.get("selected_worker") or "") for worker in workers): + template["selected_worker"] = str(workers[0].get("id") or "") if workers else "" + return template + + if str(template.get("id") or "") != "generic-task": + return template + defaults = dev_pipeline_generic_blueprint_defaults() + + for key in ("label", "detail", "description", "tagline", "worker_type", "execution_model", "worker_stage_label", "factory_stage_label", "blueprint_version", "tasks_completed", "tasks_total"): + if key not in template or template.get(key) is None or (isinstance(template.get(key), str) and not str(template.get(key)).strip()): + template[key] = deepcopy(defaults[key]) + + default_workers = { + str(item.get("id") or ""): item + for item in defaults["workers"] + if isinstance(item, dict) + } + raw_workers = template.get("workers") + if isinstance(raw_workers, list): + template["workers"] = [ + dev_pipeline_merge_default_fields(item, default_workers.get(str(item.get("id") or ""), {})) + for item in raw_workers + if isinstance(item, dict) + ] + else: + template["workers"] = deepcopy(defaults["workers"]) + + default_steps = { + str(item.get("id") or ""): item + for item in defaults["factory_steps"] + if isinstance(item, dict) + } + raw_steps = template.get("factory_steps") + if isinstance(raw_steps, list): + template["factory_steps"] = [ + dev_pipeline_merge_default_fields(item, default_steps.get(str(item.get("id") or ""), {})) + for item in raw_steps + if isinstance(item, dict) + ] + else: + template["factory_steps"] = deepcopy(defaults["factory_steps"]) + + default_inputs = dev_pipeline_default_required_inputs("generic-task") + default_input_map = { + str(item.get("id") or ""): item + for item in default_inputs + if isinstance(item, dict) + } + raw_inputs = template.get("required_inputs") + if isinstance(raw_inputs, list): + template["required_inputs"] = [ + dev_pipeline_merge_default_fields(item, default_input_map.get(str(item.get("id") or ""), {})) + for item in raw_inputs + if isinstance(item, dict) + ] + else: + template["required_inputs"] = default_inputs + + workers = [worker for worker in template.get("workers", []) if isinstance(worker, dict)] + if not any(str(worker.get("id") or "") == str(template.get("selected_worker") or "") for worker in workers): + template["selected_worker"] = str(workers[0].get("id") or "") if workers else "" + return template + + +def dev_pipeline_default_required_inputs(template_id: str) -> list[dict[str, Any]]: + template_id = str(template_id or "") + defaults: dict[str, list[dict[str, Any]]] = { + HARD_PROREQ_TEMPLATE_ID: [ + { + "id": "operator-thoughts", + "title": "Operator thoughts and full plan", + "detail": "Raw request, goals, constraints, assumptions, and complete plan text from the Run Pipeline prompt or questionnaire.", + "kind": "questionnaire", + "source": "user", + "format": "structured answers", + "artifacts": ["execution/hard-proreq/latest/operator_intake.json"], + "evidence_policy": "Every hard proreq run must preserve the operator's source prompt and any questionnaire answers before model planning starts.", + "questions": [ + {"id": "intent", "prompt": "What are you trying to build or change?", "required": True, "answer_type": "text", "options": []}, + {"id": "constraints", "prompt": "Which constraints, risks, or non-negotiables matter?", "required": False, "answer_type": "text", "options": []}, + {"id": "done", "prompt": "What should be true when this project is done?", "required": True, "answer_type": "text", "options": []}, + ], + "status": "missing", + "required": True, + }, + { + "id": "generated-cento-context", + "title": "Generated mini Cento context", + "detail": "Cento-native gather-context, tool registry, repo search hits, and task-relevant files generated from the operator input.", + "kind": "path", + "source": "auto", + "automation": "cento-context", + "format": "JSON object", + "paths": ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "docs/**", "tests/**", "data/tools.json"], + "path_policy": "Use Cento-native context before asking GPT pro to plan backend work.", + "artifacts": ["execution/hard-proreq/latest/mini_cento_context.json"], + "evidence_policy": "The Pro backend request must cite a lightweight Cento context artifact generated from the prompt.", + "status": "configured", + "required": True, + }, + { + "id": "ui-screenshot-request", + "title": "Optional muted screenshot context", + "detail": "Optional local screenshot path or generated frontend screenshot request split from the same operator input; this lane stays separate and muted in Execution Flow.", + "kind": "image", + "source": "auto", + "automation": "openai-image", + "muted": True, + "blocking": False, + "format": "prompt artifact", + "image_refs": [ + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/existing_ui_reference.png", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/generated_integrator_screenshot.png", + ], + "image_notes": "Use an operator-provided local screenshot when present; otherwise generate or capture UI screenshots separately. Validate chunks against screenshot regions without giving backend planning frontend ownership.", + "artifacts": [ + "execution/hard-proreq/latest/ui_screenshot_request.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/existing_ui_reference.png", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/generated_integrator_screenshot.png", + ], + "evidence_policy": "Frontend visual work remains an optional muted lane and does not block backend story planning.", + "status": "muted", + "required": False, + }, + { + "id": "pro-backend-schema", + "title": "GPT Pro backend schema manifest", + "detail": "Strict JSON Schema used in the Responses API request and by generated Codex exec commands.", + "kind": "details", + "source": "auto", + "automation": "schema-artifact", + "format": "JSON Schema", + "artifacts": ["execution/hard-proreq/latest/pro_output_schema.json", "execution/hard-proreq/latest/pro_backend_request.json"], + "evidence_policy": "GPT Pro must return the backend plan through the lightweight hard proreq schema.", + "status": "configured", + "required": True, + }, + { + "id": "backend-work-handoff", + "title": "10-story backend handoff", + "detail": "Cento-native ten story manifests, parallel patch workset, manifest integration policy, validation plan, and Codex exec command scaffolding produced from the Pro output.", + "kind": "evidence", + "source": "auto", + "automation": "evidence-handoff", + "format": "artifact list", + "artifacts": [ + "execution/hard-proreq/latest/backend_work_manifest.json", + "execution/hard-proreq/latest/story_index.json", + "execution/hard-proreq/latest/parallel_patch_workset.json", + "execution/hard-proreq/latest/integration_plan.json", + "execution/hard-proreq/latest/validation_plan.json", + "execution/hard-proreq/latest/hard_proreq_evidence.json", + ], + "evidence_policy": "Backend work must be split into ten story manifests, owned paths, dependency order, validation commands, and handoff artifacts before parallel patch dispatch.", + "status": "configured", + "required": True, + }, + ], + MULTIPIPELINE_TEMPLATE_ID: [ + { + "id": "multipipeline-objective", + "title": "Multipipeline objective", + "detail": "The operator goal, target areas, boundaries, and definition of success for the four sequential ProReq passes.", + "kind": "questionnaire", + "source": "user", + "format": "structured answers", + "artifacts": ["execution/multipipeline/latest/operator_intake.json"], + "evidence_policy": "Every meta-pipeline run must preserve the operator objective before scheduling child ProReq pass requests.", + "questions": [ + {"id": "objective", "prompt": "What should this four-pass ProReq chain achieve?", "required": True, "answer_type": "long", "options": []}, + {"id": "areas", "prompt": "Which areas, systems, or requirements should the four passes cover?", "required": True, "answer_type": "long", "options": []}, + {"id": "boundaries", "prompt": "Which work, spend, dispatch, or repository changes are forbidden unless explicitly approved?", "required": True, "answer_type": "long", "options": []}, + {"id": "handoff", "prompt": "What proves each pass produced usable guidance for the next pass?", "required": True, "answer_type": "long", "options": []}, + ], + "status": "missing", + "required": True, + }, + { + "id": "multipipeline-schedule-config", + "title": "Sequential schedule controls", + "detail": "Four-pass chain configuration: child pipeline, dispatch mode, UI screenshot request mode, Pro request mode, and guidance handoff policy.", + "kind": "details", + "source": "user", + "format": "structured controls", + "artifacts": ["inputs/multipipeline-proreq-chain_multipipeline-schedule-config.json", "execution/multipipeline/latest/multipipeline_schedule.json"], + "evidence_policy": "The run must schedule exactly four ordered ProReq passes by default and keep live Pro/image execution opt-in.", + "status": "provided", + "required": True, + "answer": "passes: 4\nchild_pipeline: hard-proreq-task\nexecution_mode: request-artifacts\nui_screenshot: request-artifact\npro_call: request-artifact\nhandoff_policy: previous-guidance-required", + }, + { + "id": "multipipeline-context", + "title": "Generated Cento route context", + "detail": "Cento-native tool registry, Dev Pipeline contract, ProReq route, parallel pipeline route, and repo context used by all four passes.", + "kind": "path", + "source": "auto", + "automation": "cento-context", + "format": "path list", + "paths": ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "docs/**", "tests/**", "data/tools.json", ".cento/api_workers.yaml"], + "path_policy": "Use existing Cento routes and lowest-compute request artifacts before any live model dispatch.", + "artifacts": ["execution/multipipeline/latest/multipipeline_schedule.json"], + "evidence_policy": "Each ProReq pass request must cite the same Cento route context and previous pass guidance.", + "status": "configured", + "required": True, + }, + { + "id": "ui-screenshot-request", + "title": "UI screenshot guidance request", + "detail": "Auto-generated ChatGPT image prompt for the Dev Pipeline Studio UI showing the four sequential passes, Pro request, validation, and evidence handoff.", + "kind": "image", + "source": "auto", + "automation": "openai-image-request", + "muted": True, + "blocking": False, + "format": "prompt artifact", + "image_refs": [], + "image_notes": "Request-only by default; use an optional operator screenshot as style context when provided.", + "artifacts": ["execution/multipipeline/latest/ui_screenshot_request.json"], + "evidence_policy": "UI guidance remains a separate muted artifact and does not trigger live image generation unless the operator opts in.", + "status": "muted", + "required": False, + }, + { + "id": "multipipeline-pro-request", + "title": "ChatGPT Pro chain request", + "detail": "Strict prompt/request artifact asking ChatGPT Pro for manifests, integration guidance, validation guidance, next steps, and cost-aware model usage guidance.", + "kind": "details", + "source": "auto", + "automation": "proreq-pro-request", + "format": "JSON request artifact", + "artifacts": ["execution/multipipeline/latest/chatgpt_pro_request.json"], + "evidence_policy": "Live Pro dispatch is skipped unless explicitly enabled; the request artifact is still ready for ChatGPT Pro.", + "status": "configured", + "required": True, + }, + { + "id": "multipipeline-evidence", + "title": "Sequential chain evidence", + "detail": "Pass receipts, pass guidance artifacts, UI screenshot request, ChatGPT Pro request, roadmap, and validation summary.", + "kind": "evidence", + "source": "auto", + "automation": "multipipeline-evidence-handoff", + "format": "artifact bundle", + "artifacts": [ + "execution/multipipeline/latest/multipipeline_schedule.json", + "execution/multipipeline/latest/pass_01_guidance.json", + "execution/multipipeline/latest/pass_02_guidance.json", + "execution/multipipeline/latest/pass_03_guidance.json", + "execution/multipipeline/latest/pass_04_guidance.json", + "execution/multipipeline/latest/chain_roadmap.md", + "execution/multipipeline/latest/multipipeline_evidence.json", + ], + "evidence_policy": "A meta-pipeline run is complete only when all four pass requests and the final evidence handoff exist.", + "status": "configured", + "required": True, + }, + ], + PARALLEL_PIPELINE_TEMPLATE_ID: [ + { + "id": "parallel-objective", + "title": "Parallel pipeline objective", + "detail": "Operator goal, acceptance criteria, risk limits, and completion definition for this parallel workset run.", + "kind": "questionnaire", + "source": "user", + "format": "structured answers", + "artifacts": ["execution/parallel/latest/objective.json"], + "evidence_policy": "Every parallel run must preserve the objective before workset generation and worker dispatch.", + "questions": [ + {"id": "goal", "prompt": "What should the parallel pipeline change or produce?", "required": True, "answer_type": "text", "options": []}, + {"id": "acceptance", "prompt": "What proves each worker and the integrator succeeded?", "required": True, "answer_type": "text", "options": []}, + {"id": "risks", "prompt": "Which risks or forbidden changes should block dispatch?", "required": False, "answer_type": "text", "options": []}, + ], + "status": "missing", + "required": True, + }, + { + "id": "parallel-workstreams", + "title": "Advanced workstream override", + "detail": "Optional expert override. Leave collapsed so Cento can generate worker lanes and exclusive write paths from the objective.", + "kind": "path", + "source": "user", + "format": "optional path list or JSON workstreams", + "paths": [], + "path_policy": "When supplied, every path must be exclusive to one worker. Shared-file or overlapping path work belongs in the serialized integrator step, not parallel workers.", + "artifacts": ["execution/worksets/latest.json", "execution/worksets/.json"], + "evidence_policy": "The generated workset makes write_paths explicit before any worker is allowed to run.", + "status": "configured", + "required": False, + "advanced": True, + }, + { + "id": "parallel-read-context", + "title": "Generated parallel read context", + "detail": "Cento-native read paths, tool contracts, API worker config, and repo context used by all parallel workers.", + "kind": "path", + "source": "auto", + "automation": "cento-context", + "format": "path list", + "paths": ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "docs/**", "tests/**", "data/tools.json", ".cento/api_workers.yaml"], + "path_policy": "Shared read context is allowed; writes remain exclusive per worker.", + "artifacts": ["inputs/parallel-pipeline_parallel-read-context.json"], + "evidence_policy": "Workers must receive a common read context and never infer extra write scope.", + "status": "configured", + "required": True, + }, + { + "id": "parallel-ui-config", + "title": "Parallel UI and runtime config", + "detail": "Max parallelism, runtime profile, budget, validation mode, and Execution Flow display policy for the parallel run.", + "kind": "details", + "source": "user", + "format": "structured controls", + "artifacts": ["inputs/parallel-pipeline_parallel-ui-config.json", "execution/parallel_execution_manifest.json"], + "evidence_policy": "Execution UI must show worker fan-out, max parallelism, serialized integration, validation, and evidence convergence.", + "status": "provided", + "required": True, + "answer": "max_parallel: 10\nruntime: fixture\nintegrator: sequential\nvalidation: smoke\napply_mode: dry-run\nbudget_usd: 0.00\nmax_budget_usd: 0.00", + }, + { + "id": "parallel-integrator-gate", + "title": "Serialized integration gate", + "detail": "Auto-generated evidence contract proving that worker patches converge through a single sequential integrator.", + "kind": "evidence", + "source": "auto", + "automation": "sequential-integrator", + "format": "receipt list", + "artifacts": [".cento/worksets/*/workset_receipt.json", ".cento/worksets/*/integration/**", "integration_receipts/*.json"], + "evidence_policy": "Parallel workers may run concurrently, but all accepted patches must be applied through one serialized integration lane.", + "status": "configured", + "required": True, + }, + { + "id": "parallel-validation-evidence", + "title": "Parallel validation and handoff evidence", + "detail": "Validator receipts, worker receipts, costs, changed paths, logs, and residual risk notes for review.", + "kind": "evidence", + "source": "auto", + "automation": "parallel-evidence-handoff", + "format": "artifact bundle", + "artifacts": ["validation/validation_receipt.json", "evidence/evidence_bundle.json", "execution/delivery//workset.stdout.log", "execution/delivery//workset.stderr.log"], + "evidence_policy": "A run is reviewable only when worker outcomes, integration receipts, validation receipts, and cost facts are linked.", + "status": "configured", + "required": True, + }, + ], + PATCH_SWARM_TEMPLATE_ID: [ + { + "id": "patch-swarm-objective", + "title": "Patch Swarm objective", + "detail": "Operator goal, acceptance criteria, risk limits, and the target patch market outcome.", + "kind": "questionnaire", + "source": "user", + "format": "structured answers", + "artifacts": ["execution/patch-swarm/latest/patch_swarm_manifest.json"], + "evidence_policy": "Every Patch Swarm run must preserve the objective before ProReq lane dispatch.", + "questions": [ + {"id": "goal", "prompt": "What should the patch swarm improve or build?", "required": True, "answer_type": "text", "options": []}, + {"id": "acceptance", "prompt": "What proves a candidate patch is worth integrating?", "required": True, "answer_type": "text", "options": []}, + {"id": "risk", "prompt": "Which files, costs, or behaviors must block candidates?", "required": False, "answer_type": "text", "options": []}, + ], + "status": "missing", + "required": True, + }, + { + "id": "patch-swarm-provider-policy", + "title": "Provider and cost policy", + "detail": "Provider mix, candidate target, max active agents, live/fixture mode, and budget controls.", + "kind": "details", + "source": "user", + "format": "structured controls", + "artifacts": ["execution/patch-swarm/latest/cost_policy.json"], + "evidence_policy": "Provider and budget policy must be visible before any live model or local agent dispatch.", + "status": "provided", + "required": True, + "answer": "candidate_target: 100\nmax_parallel_agents: 5\nproviders: codex-exec,claude-code,api-openai\nmode: fixture\nbudget_usd: 0.00\nmax_budget_usd: 0.00", + }, + { + "id": "patch-swarm-runtime-context", + "title": "Runtime adapter context", + "detail": "Cento runtime profiles, API worker schema, Workset materializer, and Safe Integrator context.", + "kind": "path", + "source": "auto", + "automation": "cento-context", + "format": "path list", + "paths": ["scripts/parallel_delivery.py", "scripts/cento_workset.py", "scripts/cento_openai_worker.py", ".cento/runtimes.yaml", ".cento/api_workers.yaml", "templates/agent-work-app/**"], + "path_policy": "Providers must converge on candidate_patch.v1 and never mutate the operator worktree directly.", + "artifacts": ["execution/patch-swarm/latest/proreq_execution_manifest.json"], + "evidence_policy": "Runtime adapters must be listed in the manifest before candidates are generated.", + "status": "configured", + "required": True, + }, + { + "id": "patch-swarm-integrator-gate", + "title": "Dedicated integrator gate", + "detail": "One serialized integration execution consumes all ten ProReq lane outputs and selects winners.", + "kind": "evidence", + "source": "auto", + "automation": "safe-integrator-handoff", + "format": "artifact bundle", + "artifacts": ["execution/patch-swarm/latest/integration_execution.json", "execution/patch-swarm/latest/safe_integrator_handoff.json"], + "evidence_policy": "A run is reviewable only after the dedicated integrator writes a Safe Integrator handoff.", + "status": "configured", + "required": True, + }, + { + "id": "patch-swarm-validation-evidence", + "title": "Candidate validation and ranking evidence", + "detail": "Candidate index, dedupe clusters, ranking, cost ledger, validation summary, and residual risks.", + "kind": "evidence", + "source": "auto", + "automation": "patch-swarm-validation", + "format": "artifact bundle", + "artifacts": ["execution/patch-swarm/latest/candidate_index.json", "execution/patch-swarm/latest/ranking.json", "execution/patch-swarm/latest/validation_summary.json"], + "evidence_policy": "Candidate count, provider mix, validation, ranking, and cost facts must be visible in the UI.", + "status": "configured", + "required": True, + }, + ], + "generic-task": [ + { + "id": "input-manifest", + "title": "Input manifest", + "detail": "Task kind, surface, target paths, allowed changes, forbidden changes, and acceptance boundaries", + "kind": "details", + "format": "JSON object", + "artifacts": ["workspace/runs/generic-task/outputs/scope.json", "workspace/runs/generic-task/outputs/plan.json"], + "evidence_policy": "The input manifest must identify the task kind, expected behavior, target surface, allowed changes, forbidden changes, and acceptance boundary before workers start.", + "status": "provided", + "required": True, + }, + { + "id": "repo-context-manifest", + "title": "Repo context manifest", + "detail": "Languages, test commands, lint commands, ownership hints, and dependency graph source", + "kind": "path", + "paths": ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "docs/**", "tests/**"], + "path_policy": "Discover repo contracts before any code synthesis or Factory execution", + "artifacts": ["workspace/runs/dev-pipeline-studio/docs-pages/latest/workers/generic-task_repo-context.json"], + "evidence_policy": "Repo context must name existing conventions, runnable validation commands, and ownership hints before change planning starts.", + "status": "configured", + "required": True, + }, + { + "id": "change-blueprint-contract", + "title": "Change blueprint contract", + "detail": "Structured change units, test units, expected symbols, and ambiguity gates", + "kind": "questionnaire", + "questions": [ + {"id": "q-1", "prompt": "Which bounded behavior should change?", "required": True, "answer_type": "text", "options": []}, + {"id": "q-2", "prompt": "Which files or symbols are expected targets?", "required": False, "answer_type": "text", "options": []}, + {"id": "q-3", "prompt": "Which cases must focused tests cover?", "required": True, "answer_type": "multi-select", "options": ["success", "failure", "edge_case", "regression"]}, + ], + "artifacts": ["workspace/runs/dev-pipeline-studio/docs-pages/latest/workers/generic-task_change-blueprint.json"], + "evidence_policy": "Blueprint must map each change unit to an owned path, validation command, rollback note, and handoff artifact.", + "status": "configured", + "required": True, + }, + { + "id": "execution-manifest", + "title": "Execution manifest", + "detail": "Checkout, snapshot, apply change units, formatters, tests, diff collection, logs, and rollback limits", + "kind": "details", + "format": "JSON object", + "artifacts": [ + "workspace/runs/dev-pipeline-studio/docs-pages/latest/integration/configs/repo-context.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/integration/configs/change-blueprint.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/integration/integration_lane.json" + ], + "evidence_policy": "Factory execution must have checkout, snapshot, apply, formatting, test, diff, log, and rollback steps configured before handoff.", + "status": "configured", + "required": True, + }, + { + "id": "validation-evidence-manifest", + "title": "Validation and evidence manifest", + "detail": "Deterministic checks first; optional AI review only on large diffs, failures, or ambiguous acceptance", + "kind": "evidence", + "artifacts": [ + "diff.patch", + "test-output.txt", + "validation-report.json", + "acceptance-map.md", + "risk-notes.md", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/validation/validation_receipt.json", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/evidence/evidence_bundle.json" + ], + "evidence_policy": "Factory executes the blueprint; validators decide acceptability before human handoff, and the evidence bundle must include manifest, integration, validation, budget, Taskstream, and handoff artifacts.", + "status": "configured", + "required": True, + }, + ], + "doc-page": [ + { + "id": "page-brief", + "title": "Page brief", + "detail": "Audience, page objective, product surface, and desired outcome", + "kind": "details", + "format": "markdown", + "status": "provided", + "required": True, + }, + { + "id": "reference-images", + "title": "Reference images", + "detail": "Screenshots, mockups, or visual references for the doc page", + "kind": "image", + "image_refs": ["Downloads/devpipelinestudio.png"], + "image_notes": "Inspect layout, hierarchy, visual density, and sidebar/content alignment", + "status": "configured", + "required": False, + }, + { + "id": "content-questionnaire", + "title": "Content questionnaire", + "detail": "Structured questions required before workers generate sections", + "kind": "questionnaire", + "questions": [ + {"id": "q-1", "prompt": "What is the primary reader task?", "required": True, "answer_type": "text", "options": []}, + {"id": "q-2", "prompt": "Which sections are mandatory?", "required": True, "answer_type": "multi-select", "options": ["Overview", "User guide", "Data model", "Changelog"]}, + {"id": "q-3", "prompt": "Which links or external references must be included?", "required": False, "answer_type": "text", "options": []}, + ], + "status": "configured", + "required": True, + }, + { + "id": "target-doc-paths", + "title": "Target doc paths", + "detail": "Routes and files the doc page pipeline may read or write", + "kind": "path", + "paths": ["templates/agent-work-app/index.html", "templates/agent-work-app/styles.css", "/docs#pipeline-studio-template-editor"], + "path_policy": "Workers may only change declared doc page sections and route-owned assets", + "status": "configured", + "required": True, + }, + { + "id": "evidence-requirements", + "title": "Evidence requirements", + "detail": "Screenshots, receipts, and validation outputs required for handoff", + "kind": "evidence", + "artifacts": ["workspace/runs/agent-work//typed-inputs.png", "validation/validation_receipt.json"], + "evidence_policy": "Screenshot must show the typed input editor and manifest path after save", + "status": "missing", + "required": True, + }, + ], + } + defaults[PROREQ_LIGHT_TEMPLATE_ID] = deepcopy(defaults[HARD_PROREQ_TEMPLATE_ID]) + for item in defaults[PROREQ_LIGHT_TEMPLATE_ID]: + if not isinstance(item, dict): + continue + if item.get("id") == "generated-cento-context": + item["path_policy"] = "Use Cento-native context before asking Codex Exec to simulate the Pro planning lane." + item["evidence_policy"] = "The Codex Exec ProReq-light prompt must cite a lightweight Cento context artifact generated from the operator prompt." + elif item.get("id") == "pro-backend-schema": + item["title"] = "Codex Exec ProReq schema manifest" + item["detail"] = "Strict JSON Schema used by the Codex Exec prompt that replaces the live Pro request." + item["automation"] = "codex-exec-schema-artifact" + item["artifacts"] = [ + "execution/hard-proreq/latest/pro_output_schema.json", + "execution/hard-proreq/latest/proreq_light_output_schema.json", + "execution/hard-proreq/latest/pro_backend_request.json", + "execution/hard-proreq/latest/proreq_light_codex_prompt.md", + ] + item["evidence_policy"] = "Codex Exec should return the backend plan through the same lightweight hard proreq schema as the Pro route." + elif item.get("id") == "backend-work-handoff": + item["detail"] = "Cento-native ten story manifests, parallel patch workset, manifest integration policy, validation plan, and Codex exec command scaffolding produced from the ProReq-light Codex Exec output." + return dev_pipeline_required_inputs(defaults.get(template_id, [])) + + +def dev_pipeline_template_required_inputs(template: dict[str, Any]) -> list[dict[str, Any]]: + if isinstance(template.get("required_inputs"), list): + return dev_pipeline_required_inputs(template.get("required_inputs")) + return dev_pipeline_default_required_inputs(str(template.get("id") or "")) + + +def dev_pipeline_write_input_manifests(root: Path, manifest: dict[str, Any], project: dict[str, Any], template: dict[str, Any], inputs: list[dict[str, Any]]) -> list[dict[str, Any]]: + template_id = str(template.get("id") or "pipeline") + input_manifest_rel = str(template.get("input_manifest") or f"inputs/{template_id}_input_manifest.json") + pipeline_config_rel = str(template.get("pipeline_config") or f"inputs/{template_id}_pipeline_config.json") + normalized_inputs: list[dict[str, Any]] = [] + for item in dev_pipeline_required_inputs(inputs): + input_id = str(item.get("id") or "input") + item_manifest_rel = str(item.get("manifest") or f"inputs/{template_id}_{input_id}.json") + answer = str(item.get("answer") or "") + answer_values = [str(value) for value in item.get("answer_values", []) if isinstance(value, str)] + answer_notes = str(item.get("answer_notes") or "") + answer_present = bool(answer.strip() or answer_values or answer_notes.strip()) + provided_at = str(item.get("provided_at") or "") + if answer_present and not provided_at: + provided_at = datetime.now(timezone.utc).isoformat() + typed_payload = { + "schema_version": "cento.input_manifest.v1", + "id": input_id, + "project": str(project.get("id") or ""), + "template_id": template_id, + "title": str(item.get("title") or ""), + "kind": str(item.get("kind") or item.get("input_type") or "text"), + "source": str(item.get("source") or "user"), + "automation": str(item.get("automation") or item.get("automation_source") or ""), + "automation_source": str(item.get("automation_source") or item.get("automation") or ""), + "muted": bool(item.get("muted", False)), + "blocking": bool(item.get("blocking", True)), + "format": str(item.get("format") or ""), + "status": str(item.get("status") or "missing"), + "required": bool(item.get("required", True)), + "detail": str(item.get("detail") or ""), + "image_refs": [str(value) for value in item.get("image_refs", []) if isinstance(value, str)], + "image_notes": str(item.get("image_notes") or ""), + "questions": [question for question in item.get("questions", []) if isinstance(question, dict)], + "paths": [str(value) for value in item.get("paths", []) if isinstance(value, str)], + "path_policy": str(item.get("path_policy") or ""), + "artifacts": [str(value) for value in item.get("artifacts", []) if isinstance(value, str)], + "evidence_policy": str(item.get("evidence_policy") or ""), + "answer": answer, + "answer_values": answer_values, + "answer_notes": answer_notes, + "answer_present": answer_present, + "provided_at": provided_at, + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, item_manifest_rel), typed_payload) + saved_item = deepcopy(item) + saved_item["answer"] = answer + saved_item["answer_values"] = answer_values + saved_item["answer_notes"] = answer_notes + saved_item["answer_present"] = answer_present + saved_item["provided_at"] = provided_at + saved_item["manifest"] = item_manifest_rel + normalized_inputs.append(saved_item) + + missing_required = [ + item for item in normalized_inputs + if bool(item.get("required", True)) and str(item.get("status") or "") == "missing" + ] + aggregate_manifest = { + "schema_version": "cento.input_manifest_set.v1", + "manifest_id": str(manifest.get("id") or ""), + "project": str(project.get("id") or ""), + "template_id": template_id, + "inputs": [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or ""), + "kind": str(item.get("kind") or ""), + "source": str(item.get("source") or "user"), + "automation": str(item.get("automation") or item.get("automation_source") or ""), + "muted": bool(item.get("muted", False)), + "blocking": bool(item.get("blocking", True)), + "status": str(item.get("status") or ""), + "required": bool(item.get("required", True)), + "answer_present": bool(item.get("answer_present", False)), + "answer": str(item.get("answer") or ""), + "answer_values": [str(value) for value in item.get("answer_values", []) if isinstance(value, str)], + "answer_notes": str(item.get("answer_notes") or ""), + "provided_at": str(item.get("provided_at") or ""), + "manifest": str(item.get("manifest") or ""), + } + for item in normalized_inputs + ], + "provided_count": len([item for item in normalized_inputs if bool(item.get("answer_present", False))]), + "missing_required_inputs": [str(item.get("id") or "") for item in missing_required], + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, input_manifest_rel), aggregate_manifest) + pipeline_config = { + "schema_version": "cento.pipeline_config.v1", + "manifest_id": str(manifest.get("id") or ""), + "project": str(project.get("id") or ""), + "template_id": template_id, + "input_manifest": input_manifest_rel, + "inputs": [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or ""), + "kind": str(item.get("kind") or ""), + "source": str(item.get("source") or "user"), + "automation": str(item.get("automation") or item.get("automation_source") or ""), + "muted": bool(item.get("muted", False)), + "blocking": bool(item.get("blocking", True)), + "status": str(item.get("status") or ""), + "answer": str(item.get("answer") or ""), + "answer_values": [str(value) for value in item.get("answer_values", []) if isinstance(value, str)], + "answer_notes": str(item.get("answer_notes") or ""), + "answer_present": bool(item.get("answer_present", False)), + "provided_at": str(item.get("provided_at") or ""), + } + for item in normalized_inputs + ], + "ready_for_run": not missing_required, + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, pipeline_config_rel), pipeline_config) + template["input_manifest"] = input_manifest_rel + template["pipeline_config"] = pipeline_config_rel + return normalized_inputs + + +def dev_pipeline_append_event(root: Path, manifest: dict[str, Any], event: str, project_id: str, template_id: str, details: dict[str, Any] | None = None) -> None: + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} + events_rel = str(artifacts.get("events") or "events.ndjson") + event_path = dev_pipeline_root_path(root, events_rel) + event_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "event": event, + "project_id": project_id, + "template_id": template_id, + "details": details or {}, + } + with event_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") + + +def dev_pipeline_artifact(root: Path, relative: str) -> dict[str, Any]: + clean = str(relative or "").strip() + path = root / clean if clean else root + return artifact_payload(Path(clean).name or "artifact", dev_pipeline_relative(path), source="dev-pipeline-studio") + + +def dev_pipeline_artifact_json(root: Path, relative: str) -> dict[str, Any]: + clean = str(relative or "").strip() + if not clean: + return {} + return read_json_path(root / clean) + + +def dev_pipeline_find(items: list[dict[str, Any]], requested: str, default_id: str) -> dict[str, Any]: + wanted = str(requested or default_id or "").strip() + for item in items: + aliases = [str(value) for value in item.get("aliases", []) if isinstance(value, str)] + if str(item.get("id") or "") == wanted or wanted in aliases: + return item + for item in items: + if str(item.get("id") or "") == default_id: + return item + return items[0] if items else {} + + +def dev_pipeline_worker_manifest(project: dict[str, Any], template: dict[str, Any], worker: dict[str, Any], root: Path) -> tuple[dict[str, Any], str]: + manifest_rel = str(worker.get("manifest") or "").strip() + payload = dev_pipeline_artifact_json(root, manifest_rel) + project_id = str(project.get("id") or "") + template_id = str(template.get("id") or "") + worker_id = str(worker.get("id") or "") + if payload and payload.get("project") == project_id and payload.get("template_id") == template_id: + return payload, manifest_rel + + file_name = str(worker.get("file") or f"{worker_id}.json") + read_paths = [str(value) for value in project.get("read_paths", []) if isinstance(value, str)] + read_paths.append(f"templates/pipelines/{template_id}.json") + payload = { + "schema_version": "cento.worker_manifest.v1", + "id": f"{worker_id}_worker_01", + "project": project_id, + "template_id": template_id, + "type": str(template.get("worker_type") or "pipeline_worker"), + "task_id": worker_id, + "description": f"{worker.get('description') or worker.get('title') or worker_id} for {project.get('label') or project_id} using the {template.get('label') or template_id} template", + "owned_paths": [f"{project.get('owned_root') or 'workspace/generated'}/{file_name}"], + "read_paths": read_paths, + "dependencies": [], + "acceptance": [ + f"{template.get('label') or 'Template'} output is valid", + "Template parameters are preserved", + "Only owned paths changed", + ], + "validation": {"tier": str(template.get("validation_tier") or "smoke")}, + } + return payload, manifest_rel + + +def dev_pipeline_synthesized_worker_manifest(project: dict[str, Any], template: dict[str, Any], worker: dict[str, Any]) -> dict[str, Any]: + worker_id = str(worker.get("id") or "") + file_name = str(worker.get("file") or f"{worker_id}.json") + read_paths = [str(value) for value in project.get("read_paths", []) if isinstance(value, str)] + template_id = str(template.get("id") or "") + read_paths.append(f"templates/pipelines/{template_id}.json") + return { + "schema_version": "cento.worker_manifest.v1", + "id": f"{worker_id}_worker_01", + "project": str(project.get("id") or ""), + "template_id": template_id, + "type": str(template.get("worker_type") or "pipeline_worker"), + "task_id": worker_id, + "description": f"{worker.get('description') or worker.get('title') or worker_id} for {project.get('label') or project.get('id') or 'project'}", + "owned_paths": [f"{project.get('owned_root') or 'workspace/generated'}/{file_name}"], + "read_paths": read_paths, + "dependencies": [str(value) for value in worker.get("dependencies", []) if isinstance(value, str)], + "acceptance": [ + f"{template.get('label') or 'Template'} output is valid", + "Template parameters are preserved", + "Only owned paths changed", + ], + "validation": {"tier": str(template.get("validation_tier") or "smoke")}, + } + + +def dev_pipeline_duplicate_template(root: Path, manifest: dict[str, Any], project: dict[str, Any], source_template: dict[str, Any], label_override: str = "") -> dict[str, Any]: + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + source_label = str(source_template.get("label") or source_template.get("id") or "Pipeline template") + label = label_override.strip() if label_override.strip() else f"{source_label} copy" + base_id = dev_pipeline_slug(label, f"{source_template.get('id') or 'template'}-copy") + template_id = dev_pipeline_unique_id(templates, base_id) + copied = deepcopy(source_template) + copied["id"] = template_id + copied["label"] = label + copied["slug"] = template_id + copied["detail"] = "Editable draft template" + copied["selected_worker"] = str(copied.get("selected_worker") or "") + copied_workers = [item for item in copied.get("workers", []) if isinstance(item, dict)] + for worker in copied_workers: + worker_id = str(worker.get("id") or "") + if not worker_id: + continue + worker["manifest"] = f"workers/{template_id}_{worker_id}.json" + worker.setdefault("integration_receipt", f"integration_receipts/{template_id}_{worker_id}.json") + worker_manifest = dev_pipeline_synthesized_worker_manifest(project, copied, worker) + write_json_path(dev_pipeline_root_path(root, str(worker["manifest"])), worker_manifest) + copied["workers"] = copied_workers + if not copied.get("selected_worker") and copied_workers: + copied["selected_worker"] = str(copied_workers[0].get("id") or "") + if isinstance(manifest.get("templates"), list): + manifest["templates"].append(copied) + else: + manifest["templates"] = [copied] + return copied + + +def dev_pipeline_stage_element_type(value: Any) -> str: + raw = dev_pipeline_text(value, "").lower().replace("_", "-").replace(" ", "-") + aliases = { + "inputs": "input", + "operator-input": "input", + "repo": "worker", + "repo-discovery": "worker", + "blueprint": "worker", + "change-blueprint": "worker", + "workers": "worker", + "factory": "integration", + "factory-step": "integration", + "integrate": "integration", + "validator": "validation", + "validators": "validation", + "handoff": "evidence", + "artifact": "evidence", + "artifacts": "evidence", + } + normalized = aliases.get(raw, raw) + if normalized in {"input", "worker", "integration", "validation", "evidence"}: + return normalized + raise AgentWorkAppError(f"Unsupported stage element type: {value}") + + +def dev_pipeline_stage_kind(value: Any) -> str: + raw = dev_pipeline_text(value, "").lower().replace("_", "-").replace(" ", "-") + if raw in {"blueprint", "change-blueprint", "plan"}: + return "blueprint" + return "repo" + + +def dev_pipeline_base_evidence_cards( + root: Path, + manifest: dict[str, Any], + template: dict[str, Any], + event_total: int, + budget_spent: float, + budget_cap: float, +) -> list[dict[str, Any]]: + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} + evidence_bundle_rel = str(artifacts.get("evidence_bundle") or "evidence/evidence_bundle.json") + budget_receipt_rel = str(artifacts.get("budget_receipt") or "evidence/budget_receipt.json") + pipeline_receipt_rel = str(artifacts.get("pipeline_receipt") or "evidence/pipeline_receipt.json") + taskstream_evidence_rel = str(artifacts.get("taskstream_evidence") or "evidence/taskstream_evidence.json") + events_rel = str(artifacts.get("events") or "events.ndjson") + disabled = { + dev_pipeline_slug(str(value), "") + for value in template.get("evidence_disabled", []) + if str(value).strip() + } + base_cards = [ + {"id": "pipeline-receipt", "title": "Pipeline Receipt", "file": "pipeline_receipt.json", "status": title_status(read_json_path(root / pipeline_receipt_rel).get("status") or "completed"), "path": pipeline_receipt_rel, "base": True}, + {"id": "events", "title": "Events Log", "file": "events.ndjson", "status": f"{event_total} events", "path": events_rel, "base": True}, + {"id": "evidence-bundle", "title": "Evidence Bundle", "file": "evidence_bundle.json", "status": "Attached", "path": evidence_bundle_rel, "base": True}, + {"id": "budget", "title": "Budget Receipt", "file": "budget_receipt.json", "status": f"${budget_spent:.2f} of ${budget_cap:.2f}", "path": budget_receipt_rel, "base": True}, + {"id": "taskstream", "title": "Taskstream Evidence", "file": "taskstream_evidence.json", "status": "Review", "path": taskstream_evidence_rel, "base": True}, + ] + custom_cards = [ + deepcopy(item) + for item in template.get("evidence_artifacts", []) + if isinstance(item, dict) + ] + return [ + card + for card in [*base_cards, *custom_cards] + if dev_pipeline_slug(str(card.get("id") or ""), "") not in disabled + ] + + +def dev_pipeline_execution_stage_status(items: list[dict[str, Any]]) -> str: + statuses = {str(item.get("status") or "").lower().replace(" ", "-") for item in items if isinstance(item, dict)} + if not statuses: + return "configured" + if statuses & {"failed"}: + return "failed" + if statuses & {"blocked", "rejected", "budget-blocked", "budget-exceeded", "dependency-blocked"}: + return "blocked" + if statuses & {"running", "active", "in-progress"}: + return "running" + if statuses & {"queued", "configured", "pending"}: + return "queued" + if statuses <= {"accepted", "applied", "completed", "passed", "merged", "muted", "separate-flow", "deferred"}: + return "completed" + return "configured" + + +def dev_pipeline_execution_status_label(value: Any) -> str: + raw = str(value or "configured").lower().replace("_", "-").replace(" ", "-") + if raw in {"accepted", "applied", "passed", "merged"}: + return "completed" + if raw in {"muted", "separate-flow", "deferred"}: + return "muted" + if raw in {"active", "in-progress"}: + return "running" + if raw in {"budget-blocked", "budget-exceeded", "dependency-blocked"}: + return "blocked" + if raw in {"completed", "running", "queued", "failed", "blocked", "rejected", "configured", "muted"}: + return raw + return "configured" + + +def dev_pipeline_execution_command_for_step(step_id: str) -> list[str]: + commands = { + "checkout-branch": ["git", "rev-parse", "--abbrev-ref", "HEAD"], + "snapshot-repo-state": ["git", "status", "--short"], + "apply-change-units": ["python3", "-m", "json.tool", "workspace/runs/dev-pipeline-studio/docs-pages/latest/workset.json"], + "run-formatters": ["node", "--check", "templates/agent-work-app/app.js"], + "run-focused-tests": ["python3", "-m", "py_compile", "scripts/agent_work_app.py"], + "run-full-tests": ["python3", "-m", "py_compile", "workspace/runs/agent-work/dev-pipeline-studio-execution-flow/assert_execution_flow.py"], + "collect-diff": ["git", "diff", "--stat"], + "collect-logs": ["python3", "scripts/story_manifest.py", "validate", "workspace/runs/agent-work/drafts/dev-pipeline-studio-execution-flow/story.json"], + "new-execution-step": ["python3", "-m", "json.tool", "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/execution_manifest.json"], + "collect-operator-intake": ["python3", "scripts/dev_pipeline_hard_proreq.py", "intake"], + "build-cento-context": ["python3", "scripts/dev_pipeline_hard_proreq.py", "context"], + "write-ui-screenshot-request": ["python3", "scripts/dev_pipeline_hard_proreq.py", "screenshot"], + "prepare-pro-backend-request": ["python3", "scripts/dev_pipeline_hard_proreq.py", "pro-request"], + "dispatch-pro-backend-plan": ["python3", "scripts/dev_pipeline_hard_proreq.py", "pro-plan"], + "dispatch-codex-pro-backend-plan": ["python3", "scripts/dev_pipeline_hard_proreq.py", "codex-pro-plan"], + "materialize-backend-work": ["python3", "scripts/dev_pipeline_hard_proreq.py", "backend-work"], + "write-integration-plan": ["python3", "scripts/dev_pipeline_hard_proreq.py", "integration-plan"], + "write-validation-plan": ["python3", "scripts/dev_pipeline_hard_proreq.py", "validation-plan"], + "collect-proreq-evidence": ["python3", "scripts/dev_pipeline_hard_proreq.py", "evidence"], + "collect-multipipeline-intake": ["python3", "scripts/dev_pipeline_multipipeline.py", "intake"], + "write-multipipeline-schedule": ["python3", "scripts/dev_pipeline_multipipeline.py", "schedule"], + "run-proreq-pass-1": ["python3", "scripts/dev_pipeline_multipipeline.py", "pass-1"], + "run-proreq-pass-2": ["python3", "scripts/dev_pipeline_multipipeline.py", "pass-2"], + "run-proreq-pass-3": ["python3", "scripts/dev_pipeline_multipipeline.py", "pass-3"], + "run-proreq-pass-4": ["python3", "scripts/dev_pipeline_multipipeline.py", "pass-4"], + "write-multipipeline-ui-screenshot-request": ["python3", "scripts/dev_pipeline_multipipeline.py", "ui-screenshot-request"], + "write-multipipeline-pro-request": ["python3", "scripts/dev_pipeline_multipipeline.py", "pro-request"], + "collect-multipipeline-evidence": ["python3", "scripts/dev_pipeline_multipipeline.py", "evidence"], + } + return commands.get(step_id, ["python3", "-m", "json.tool", "workspace/runs/dev-pipeline-studio/docs-pages/latest/pipeline_manifest.json"]) + + +def dev_pipeline_execution_steps(root: Path, template: dict[str, Any]) -> tuple[str, dict[str, Any], list[dict[str, Any]]]: + execution_manifest_rel = str(template.get("execution_manifest") or "execution/execution_manifest.json") + execution_manifest = dev_pipeline_artifact_json(root, execution_manifest_rel) + execution_steps = [item for item in execution_manifest.get("steps", []) if isinstance(item, dict)] + if not execution_steps: + execution_steps = [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or item.get("id") or ""), + "file": str(item.get("file") or ""), + "status": str(item.get("status") or "queued"), + "dependencies": [str(value) for value in item.get("dependencies", []) if isinstance(value, str)], + "config": str(item.get("integration_config") or ""), + "receipt": str(item.get("integration_receipt") or ""), + } + for item in template.get("factory_steps", []) + if isinstance(item, dict) + ] + if not execution_steps: + raise AgentWorkAppError("No execution steps are configured for this pipeline template") + return execution_manifest_rel, execution_manifest, execution_steps + + +def dev_pipeline_template_factory_steps(template: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or item.get("id") or ""), + "file": str(item.get("file") or ""), + "status": str(item.get("status") or "queued"), + "mode": str(item.get("mode") or ""), + "muted": bool(item.get("muted")), + "lane": str(item.get("lane") or ""), + "dependencies": [str(value) for value in item.get("dependencies", []) if isinstance(value, str)], + "config": str(item.get("integration_config") or item.get("config") or ""), + "receipt": str(item.get("integration_receipt") or item.get("receipt") or ""), + } + for item in template.get("factory_steps", []) + if isinstance(item, dict) + ] + + +def dev_pipeline_write_execution_state( + root: Path, + execution_manifest_rel: str, + execution_manifest: dict[str, Any], + run_payload: dict[str, Any], +) -> None: + run_id = str(run_payload.get("run_id") or "").strip() + updated_manifest = { + **execution_manifest, + "schema_version": "cento.execution_manifest.v1", + "source": str(run_payload.get("source") or "cento-workset-api-openai"), + "pipeline": str(run_payload.get("pipeline") or ""), + "run_id": run_id, + "run_started_at": str(run_payload.get("started_at") or ""), + "run_finished_at": str(run_payload.get("finished_at") or ""), + "status": str(run_payload.get("status") or "running"), + "steps": [item for item in run_payload.get("steps", []) if isinstance(item, dict)], + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json_path(dev_pipeline_root_path(root, execution_manifest_rel), updated_manifest) + write_json_path(dev_pipeline_root_path(root, "execution/execution_run.json"), run_payload) + if run_id and "/" not in run_id and "\\" not in run_id: + write_json_path(dev_pipeline_root_path(root, f"execution/runs/{run_id}.json"), run_payload) + + +def dev_pipeline_execution_history(root: Path, active_run_id: str = "", pipeline: str = "") -> list[dict[str, Any]]: + runs_root = root / "execution" / "runs" + rows: list[dict[str, Any]] = [] + if runs_root.exists(): + for path in sorted(runs_root.glob("*.json"), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True): + payload = read_json_path(path) + if pipeline and str(payload.get("pipeline") or "") != pipeline: + continue + run_id = str(payload.get("run_id") or path.stem) + if not run_id: + continue + started_at = parse_iso_datetime(payload.get("started_at")) + finished_at = parse_iso_datetime(payload.get("finished_at")) + run_artifacts = [item for item in payload.get("artifacts", []) if isinstance(item, dict)] + rows.append( + { + "run_id": run_id, + "status": dev_pipeline_execution_status_label(payload.get("status")), + "started": format_run_time(started_at) if started_at else "", + "finished": format_run_time(finished_at) if finished_at else "In progress", + "duration": duration_label(int(float(payload.get("duration_seconds") or 0))), + "source": str(payload.get("source") or "real-e2e"), + "pipeline": str(payload.get("pipeline") or ""), + "active": run_id == active_run_id, + "path": dev_pipeline_relative(path), + "artifact_count": len(run_artifacts), + "ready_artifact_count": len([item for item in run_artifacts if bool(item.get("exists", True))]), + } + ) + return rows[:24] + + +def dev_pipeline_seed_execution_e2e( + root: Path, + manifest: dict[str, Any], + project: dict[str, Any], + template: dict[str, Any], + trigger: dict[str, Any] | None = None, +) -> dict[str, Any]: + execution_manifest_rel, execution_manifest, execution_steps = dev_pipeline_execution_steps(root, template) + trigger = trigger if isinstance(trigger, dict) else {} + + run_started = datetime.now(timezone.utc) + run_id = f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}-{run_started.strftime('%Y%m%dT%H%M%S%fZ')}" + queued_steps: list[dict[str, Any]] = [] + for index, step in enumerate(execution_steps, start=1): + step_id = str(step.get("id") or f"step-{index}") + title = str(step.get("title") or step_id) + command = dev_pipeline_execution_command_for_step(step_id) + queued_steps.append( + { + **step, + "id": step_id, + "title": title, + "status": "queued", + "command": shlex.join(command), + "exit_code": None, + "duration": "0s", + "duration_seconds": 0, + "started_at": "", + "finished_at": "", + "stdout_tail": "", + "stderr_tail": "", + } + ) + run_payload = { + "schema_version": "cento.execution_run.v1", + "source": "real-e2e", + "run_id": run_id, + "pipeline": f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}", + "status": "running", + "started_at": run_started.isoformat(), + "finished_at": "", + "duration_seconds": 0, + "triggered_by": str(trigger.get("triggered_by") or "prompt-router"), + "issue_id": str(trigger.get("issue_id") or ""), + "issue_subject": str(trigger.get("issue_subject") or ""), + "prompt": str(trigger.get("prompt") or "")[:2000], + "stages": [ + {"id": "input", "started_at": run_started.isoformat(), "finished_at": (run_started + timedelta(seconds=1)).isoformat(), "status": "completed"}, + {"id": "repo", "started_at": (run_started + timedelta(seconds=1)).isoformat(), "finished_at": (run_started + timedelta(seconds=2)).isoformat(), "status": "completed"}, + {"id": "blueprint", "started_at": (run_started + timedelta(seconds=2)).isoformat(), "finished_at": (run_started + timedelta(seconds=3)).isoformat(), "status": "completed"}, + {"id": "factory", "started_at": run_started.isoformat(), "finished_at": "", "status": "running"}, + {"id": "validation", "started_at": "", "finished_at": "", "status": "queued"}, + {"id": "handoff", "started_at": "", "finished_at": "", "status": "queued"}, + ], + "steps": queued_steps, + "logs": [ + { + "timestamp": run_started.isoformat(), + "stage": "execution", + "source": "pipeline", + "message": str(trigger.get("message") or "Live E2E execution started"), + } + ], + "written_at": datetime.now(timezone.utc).isoformat(), + } + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + return run_payload + + +def dev_pipeline_finish_execution_e2e(root: Path, project_id: str, template_id: str, run_id: str) -> None: + with DEV_PIPELINE_EXECUTION_LOCK: + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + return + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + project = dev_pipeline_find(projects, project_id, project_id) + template = dev_pipeline_find(templates, template_id, template_id) + if not project or not template: + return + dev_pipeline_apply_generic_blueprint(template) + execution_manifest_rel, execution_manifest, execution_steps = dev_pipeline_execution_steps(root, template) + run_payload = dev_pipeline_artifact_json(root, "execution/execution_run.json") + if str(run_payload.get("run_id") or "") != run_id: + return + run_started = parse_iso_datetime(run_payload.get("started_at")) or datetime.now(timezone.utc) + run_events = [item for item in run_payload.get("logs", []) if isinstance(item, dict)] + updated_steps = [item for item in run_payload.get("steps", []) if isinstance(item, dict)] + if len(updated_steps) != len(execution_steps): + updated_steps = [ + { + **step, + "id": str(step.get("id") or f"step-{index}"), + "title": str(step.get("title") or step.get("id") or f"step-{index}"), + "status": "queued", + "command": shlex.join(dev_pipeline_execution_command_for_step(str(step.get("id") or f"step-{index}"))), + "exit_code": None, + "duration": "0s", + "duration_seconds": 0, + "started_at": "", + "finished_at": "", + "stdout_tail": "", + "stderr_tail": "", + } + for index, step in enumerate(execution_steps, start=1) + ] + + run_failed = False + for index, step in enumerate(updated_steps): + step_id = str(step.get("id") or f"step-{index + 1}") + title = str(step.get("title") or step_id) + command = dev_pipeline_execution_command_for_step(step_id) + started = datetime.now(timezone.utc) + updated_steps[index] = { + **step, + "id": step_id, + "title": title, + "status": "running", + "command": shlex.join(command), + "started_at": started.isoformat(), + "finished_at": "", + "stdout_tail": "", + "stderr_tail": "", + } + run_payload["status"] = "running" + run_payload["steps"] = updated_steps + run_payload["logs"] = [ + *run_events, + { + "timestamp": started.isoformat(), + "stage": "execution", + "source": step_id, + "message": f"{title} started", + "command": shlex.join(command), + }, + ] + run_payload["written_at"] = datetime.now(timezone.utc).isoformat() + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + result = subprocess.run(command, cwd=ROOT_DIR, text=True, capture_output=True, timeout=30) + elapsed = (datetime.now(timezone.utc) - started).total_seconds() + if elapsed < DEV_PIPELINE_EXECUTION_MIN_STEP_SECONDS: + time.sleep(DEV_PIPELINE_EXECUTION_MIN_STEP_SECONDS - elapsed) + finished = datetime.now(timezone.utc) + duration_seconds = max(1, int(round((finished - started).total_seconds()))) + status = "completed" if result.returncode == 0 else "failed" + if result.returncode != 0: + run_failed = True + updated_steps[index] = { + **step, + "id": step_id, + "title": title, + "status": status, + "command": shlex.join(command), + "exit_code": result.returncode, + "duration": duration_label(duration_seconds), + "duration_seconds": duration_seconds, + "started_at": started.isoformat(), + "finished_at": finished.isoformat(), + "stdout_tail": result.stdout[-1200:], + "stderr_tail": result.stderr[-1200:], + } + run_events.append( + { + "timestamp": started.isoformat(), + "stage": "execution", + "source": step_id, + "message": f"{title} executed: {status}", + "command": shlex.join(command), + "exit_code": result.returncode, + } + ) + run_payload["status"] = "failed" if run_failed else "running" + run_payload["steps"] = updated_steps + run_payload["logs"] = run_events + run_payload["written_at"] = datetime.now(timezone.utc).isoformat() + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + if run_failed: + break + + if run_failed: + for index in range(index + 1, len(updated_steps)): + step = updated_steps[index] + updated_steps[index] = { + **step, + "status": "blocked", + "exit_code": None, + "duration": "0s", + "duration_seconds": 0, + "started_at": "", + "finished_at": "", + "stdout_tail": "", + "stderr_tail": "Skipped because an upstream execution step failed.", + } + + finished_values = [parse_iso_datetime(step.get("finished_at")) for step in updated_steps] + run_finished = max((value for value in finished_values if value is not None), default=datetime.now(timezone.utc)) + run_status = "failed" if run_failed else "completed" + factory_started = parse_iso_datetime(updated_steps[0].get("started_at")) or run_started + factory_finished = max((parse_iso_datetime(step.get("finished_at")) or factory_started for step in updated_steps), default=factory_started) + stages = [ + {"id": "input", "started_at": run_started.isoformat(), "finished_at": (run_started + timedelta(seconds=1)).isoformat(), "status": "completed"}, + {"id": "repo", "started_at": (run_started + timedelta(seconds=1)).isoformat(), "finished_at": (run_started + timedelta(seconds=2)).isoformat(), "status": "completed"}, + {"id": "blueprint", "started_at": (run_started + timedelta(seconds=2)).isoformat(), "finished_at": (run_started + timedelta(seconds=3)).isoformat(), "status": "completed"}, + {"id": "factory", "started_at": factory_started.isoformat(), "finished_at": factory_finished.isoformat(), "status": run_status}, + {"id": "validation", "started_at": factory_finished.isoformat(), "finished_at": run_finished.isoformat(), "status": run_status}, + {"id": "handoff", "started_at": run_finished.isoformat(), "finished_at": run_finished.isoformat(), "status": run_status}, + ] + run_payload["status"] = run_status + run_payload["finished_at"] = run_finished.isoformat() + run_payload["duration_seconds"] = max(0, int(round((run_finished - run_started).total_seconds()))) + run_payload["stages"] = stages + run_payload["steps"] = updated_steps + run_payload["logs"] = run_events + run_payload["written_at"] = datetime.now(timezone.utc).isoformat() + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + manifest["active_run_id"] = run_id + manifest["status"] = run_status + manifest["status_detail"] = "Execution Flow live E2E completed; execution manifest, run receipt, timestamps, logs, and animation source are in sync" + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + "pipeline_run_execution_e2e_finished", + str(project.get("id") or project_id), + str(template.get("id") or template_id), + {"execution_run_id": run_id, "status": run_status}, + ) + + +def dev_pipeline_spawn_execution_e2e(root: Path, project_id: str, template_id: str, run_id: str) -> None: + thread = threading.Thread( + target=dev_pipeline_finish_execution_e2e, + args=(root, project_id, template_id, run_id), + name=f"dev-pipeline-execution-{run_id}", + daemon=True, + ) + thread.start() + + +DEV_PIPELINE_DELIVERY_BUDGET_USD = float(os.environ.get("CENTO_PIPELINE_DELIVERY_BUDGET_USD", "10.00")) +DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD = float(os.environ.get("CENTO_PIPELINE_DELIVERY_MAX_BUDGET_USD", "20.00")) +DEV_PIPELINE_DELIVERY_API_PROFILE = os.environ.get("CENTO_PIPELINE_DELIVERY_API_PROFILE", "api-section-worker") +DEV_PIPELINE_DELIVERY_OUTPUT_SCHEMA = "patch_proposal.v1" +DEV_PIPELINE_DELIVERY_TIMEOUT_SECONDS = int(os.environ.get("CENTO_PIPELINE_DELIVERY_TIMEOUT_SECONDS", "90")) +DEV_PIPELINE_DELIVERY_REDIRECT_GRACE_SECONDS = float(os.environ.get("CENTO_PIPELINE_DELIVERY_REDIRECT_GRACE_SECONDS", "2.5")) +DEV_PIPELINE_INTEGRATION_MODEL_CEILING = os.environ.get("CENTO_PIPELINE_INTEGRATION_MODEL_CEILING", "gpt-4.1-mini") +DEV_PIPELINE_PATH_TOKEN_RE = re.compile( + r"(? str: + return re.sub(r"[^a-zA-Z0-9]+", "_", value.strip().lower()).strip("_")[:52] or "task" + + +def dev_pipeline_env_reference(value: str) -> str: + match = re.fullmatch(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*))?\}", str(value or "").strip()) + if not match: + return str(value or "").strip() + return os.environ.get(match.group(1), match.group(2) or "").strip() + + +def dev_pipeline_api_worker_config() -> tuple[dict[str, Any], list[str]]: + path = ROOT_DIR / ".cento" / "api_workers.yaml" + errors: list[str] = [] + if not path.exists(): + return {}, [f"API worker config is missing: {dev_pipeline_relative(path)}"] + try: + import yaml # type: ignore + + payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception as exc: + return {}, [f"API worker config could not be loaded: {exc}"] + if not isinstance(payload, dict): + return {}, ["API worker config must be a mapping"] + openai_config = payload.get("openai") if isinstance(payload.get("openai"), dict) else {} + if openai_config.get("enabled") is False: + errors.append("OpenAI API workers are disabled in .cento/api_workers.yaml") + profiles = payload.get("profiles") if isinstance(payload.get("profiles"), dict) else {} + profile = profiles.get(DEV_PIPELINE_DELIVERY_API_PROFILE) if isinstance(profiles.get(DEV_PIPELINE_DELIVERY_API_PROFILE), dict) else {} + if not profile: + errors.append(f"API worker profile is missing: {DEV_PIPELINE_DELIVERY_API_PROFILE}") + model = dev_pipeline_env_reference(str(profile.get("model") or "")) + if not model: + errors.append(f"API worker model is not configured for profile {DEV_PIPELINE_DELIVERY_API_PROFILE}") + if not os.environ.get("OPENAI_API_KEY"): + errors.append("OPENAI_API_KEY is not set") + return payload, errors + + +def dev_pipeline_clean_target_path(candidate: str) -> str: + value = str(candidate or "").strip().strip(".,;:()[]{}<>") + value = value.replace("\\", "/").lstrip("./") + if not value or "://" in value or "*" in value or value.startswith("#"): + return "" + if Path(value).is_absolute(): + try: + value = Path(value).resolve().relative_to(ROOT_DIR.resolve()).as_posix() + except ValueError: + return "" + parts = [part for part in value.split("/") if part] + if not parts or any(part == ".." for part in parts): + return "" + normalized = "/".join(parts) + if any(normalized == prefix or normalized.startswith(f"{prefix}/") for prefix in DEV_PIPELINE_PROTECTED_WRITE_PREFIXES): + return "" + path = ROOT_DIR / normalized + if path.exists() and path.is_dir(): + return "" + has_file_suffix = bool(Path(normalized).suffix) + if not has_file_suffix and not path.is_file(): + return "" + return normalized + + +def dev_pipeline_extract_target_paths_from_text(text: str) -> list[str]: + candidates: list[str] = [] + for match in DEV_PIPELINE_QUOTED_TOKEN_RE.finditer(str(text or "")): + raw = next((group for group in match.groups() if group), "") + if raw: + candidates.append(raw) + candidates.extend(match.group(0) for match in DEV_PIPELINE_PATH_TOKEN_RE.finditer(str(text or ""))) + paths: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + clean = dev_pipeline_clean_target_path(candidate) + if clean and clean not in seen: + seen.add(clean) + paths.append(clean) + return paths + + +def dev_pipeline_delivery_prompt(project: dict[str, Any], template: dict[str, Any], trigger: dict[str, Any]) -> str: + prompt_parts = [ + str(trigger.get("prompt") or "").strip(), + str(trigger.get("issue_subject") or "").strip(), + str(trigger.get("message") or "").strip(), + ] + previous = dev_pipeline_artifact_json(DEV_PIPELINE_STUDIO_ROOT, "execution/execution_run.json") + if not any(prompt_parts): + prompt_parts.extend([str(previous.get("prompt") or ""), str(previous.get("issue_subject") or "")]) + for item in template.get("required_inputs", []): + if not isinstance(item, dict): + continue + prompt_parts.extend( + [ + str(item.get("answer") or ""), + " ".join(str(value) for value in item.get("answer_values", []) if isinstance(value, str)), + " ".join(str(value) for value in item.get("paths", []) if isinstance(value, str)), + ] + ) + prompt = "\n\n".join(part for part in prompt_parts if part) + if not prompt: + prompt = str(template.get("description") or project.get("surface") or "").strip() + return prompt[:8000] + + +def dev_pipeline_delivery_target_paths(project: dict[str, Any], template: dict[str, Any], trigger: dict[str, Any]) -> list[str]: + prompt_parts = [str(trigger.get("prompt") or ""), str(trigger.get("issue_subject") or "")] + if not any(part.strip() for part in prompt_parts): + previous = dev_pipeline_artifact_json(DEV_PIPELINE_STUDIO_ROOT, "execution/execution_run.json") + prompt_parts.extend([str(previous.get("prompt") or ""), str(previous.get("issue_subject") or "")]) + for item in template.get("required_inputs", []): + if not isinstance(item, dict): + continue + label = f"{item.get('id') or ''} {item.get('title') or ''} {item.get('detail') or ''}".lower() + if not any(token in label for token in ("target", "write", "owned", "change blueprint", "expected target", "allowed change")): + continue + prompt_parts.extend( + [ + str(item.get("answer") or ""), + " ".join(str(value) for value in item.get("answer_values", []) if isinstance(value, str)), + " ".join(str(value) for value in item.get("paths", []) if isinstance(value, str)), + ] + ) + paths = dev_pipeline_extract_target_paths_from_text("\n".join(part for part in prompt_parts if part)) + paths = [ + path + for path in paths + if not any(other != path and other.endswith(f"/{path}") for other in paths) + ] + return paths[:8] + + +def dev_pipeline_template_is_parallel_workset(template: dict[str, Any]) -> bool: + return str(template.get("id") or "") == PARALLEL_PIPELINE_TEMPLATE_ID + + +def dev_pipeline_template_is_patch_swarm(template: dict[str, Any]) -> bool: + return str(template.get("id") or "") == PATCH_SWARM_TEMPLATE_ID + + +def dev_pipeline_int_value(value: Any, default: int, minimum: int = 1, maximum: int = 12) -> int: + try: + result = int(float(str(value).strip())) + except (TypeError, ValueError): + result = default + return max(minimum, min(maximum, result)) + + +def dev_pipeline_parallel_input_text(template: dict[str, Any], trigger: dict[str, Any]) -> str: + parts = [str(trigger.get("prompt") or ""), str(trigger.get("issue_subject") or "")] + for item in template.get("required_inputs", []): + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "") + if item_id not in {"parallel-objective", "parallel-workstreams", "parallel-ui-config"}: + continue + parts.extend( + [ + str(item.get("answer") or ""), + str(item.get("answer_notes") or ""), + "\n".join(str(value) for value in item.get("answer_values", []) if isinstance(value, str)), + "\n".join(str(value) for value in item.get("paths", []) if isinstance(value, str)), + ] + ) + return "\n".join(part for part in parts if part).strip() + + +def dev_pipeline_parallel_max_parallel(template: dict[str, Any], trigger: dict[str, Any]) -> int: + text = dev_pipeline_parallel_input_text(template, trigger) + match = re.search(r"\bmax[_ -]?parallel(?:ism)?\b\s*[:=]?\s*(\d+)", text, flags=re.IGNORECASE) + if match: + return dev_pipeline_int_value(match.group(1), dev_pipeline_int_value(template.get("max_parallel"), 4)) + match = re.search(r"\bparallel(?:ism)?\b\s*[:=]?\s*(\d+)", text, flags=re.IGNORECASE) + if match: + return dev_pipeline_int_value(match.group(1), dev_pipeline_int_value(template.get("max_parallel"), 4)) + return dev_pipeline_int_value(template.get("max_parallel"), 4) + + +def dev_pipeline_parallel_config_map(template: dict[str, Any], trigger: dict[str, Any]) -> dict[str, str]: + text = dev_pipeline_parallel_input_text(template, trigger) + config: dict[str, str] = {} + for line in text.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + key = re.sub(r"[^a-z0-9_]+", "_", key.strip().lower()).strip("_") + value = value.strip() + if key and value: + config[key] = value + return config + + +def dev_pipeline_parallel_float_config(config: dict[str, str], key: str, default: float, minimum: float = 0.0, maximum: float = 100.0) -> float: + try: + value = float(str(config.get(key, "")).strip()) + except (TypeError, ValueError): + value = default + return max(minimum, min(maximum, value)) + + +def dev_pipeline_parallel_runtime_config(template: dict[str, Any], trigger: dict[str, Any]) -> dict[str, Any]: + config = dev_pipeline_parallel_config_map(template, trigger) + runtime = str(config.get("runtime") or "fixture").strip().lower() + runtime_aliases = { + "fixture dry run": "fixture", + "fixture-dry-run": "fixture", + "dry-run": "fixture", + "api workers": "api-openai", + "api": "api-openai", + "openai": "api-openai", + } + runtime = runtime_aliases.get(runtime, runtime) + if runtime not in {"fixture", "api-openai", "local-command"}: + runtime = "fixture" + apply_mode = str(config.get("apply_mode") or config.get("apply") or "dry-run").strip().lower() + apply_enabled = apply_mode in {"apply", "sequential", "yes", "true", "1"} + max_parallel = dev_pipeline_parallel_max_parallel(template, trigger) + default_budget = 0.0 if runtime == "fixture" else DEV_PIPELINE_DELIVERY_BUDGET_USD + default_max_budget = 0.0 if runtime == "fixture" else DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD + return { + "max_parallel": max_parallel, + "runtime": runtime, + "integrator": str(config.get("integrator") or "sequential").strip().lower() or "sequential", + "validation": str(config.get("validation") or "smoke").strip().lower() or "smoke", + "apply_mode": "apply" if apply_enabled else "dry-run", + "apply_enabled": apply_enabled, + "budget_usd": dev_pipeline_parallel_float_config(config, "budget_usd", default_budget), + "max_budget_usd": dev_pipeline_parallel_float_config(config, "max_budget_usd", default_max_budget), + } + + +def dev_pipeline_parallel_auto_target_paths(project: dict[str, Any], run_id: str, runtime_config: dict[str, Any]) -> list[str]: + max_parallel = dev_pipeline_int_value(runtime_config.get("max_parallel"), 10) + if str(runtime_config.get("runtime") or "") == "fixture": + return PARALLEL_PIPELINE_FIXTURE_TARGET_PATHS[:max_parallel] + owned_root = str(project.get("owned_root") or "workspace/runs/parallel-pipeline/outputs").strip().strip("/") + return [f"{owned_root}/{run_id}/worker-{index:02d}.md" for index in range(1, max_parallel + 1)] + + +def dev_pipeline_parallel_task_from_mapping(item: dict[str, Any], index: int, read_paths: list[str], prompt: str) -> dict[str, Any] | None: + write_paths = dev_pipeline_text_list(item.get("write_paths") or item.get("paths") or item.get("owned_paths"), []) + write_paths = [dev_pipeline_clean_target_path(path) for path in write_paths] + write_paths = [path for path in write_paths if path] + if not write_paths: + return None + task_id = dev_pipeline_workset_slug(str(item.get("id") or item.get("task_id") or f"parallel-{index}")) + task_title = str(item.get("task") or item.get("title") or f"Parallel workstream {index}").strip() + description = str(item.get("description") or "").strip() + if not description: + description = ( + "Implement this independent parallel workstream using only its exclusive write paths. " + "Return complete UTF-8 contents for every changed owned path using patch_proposal.v1. " + "Do not edit files outside write_paths. " + f"\n\nOverall prompt:\n{prompt}" + ) + return { + "id": task_id, + "worker_id": str(item.get("worker_id") or f"api-parallel-worker-{index}"), + "task": task_title[:240], + "description": description, + "write_paths": write_paths, + "read_paths": dev_pipeline_text_list(item.get("read_paths"), read_paths), + "routes": dev_pipeline_text_list(item.get("routes"), []), + "depends_on": dev_pipeline_text_list(item.get("depends_on") or item.get("dependencies"), []), + "api_profile": str(item.get("api_profile") or DEV_PIPELINE_DELIVERY_API_PROFILE), + "output_schema": str(item.get("output_schema") or DEV_PIPELINE_DELIVERY_OUTPUT_SCHEMA), + "cost_usd_estimate": float(item.get("cost_usd_estimate") or 0.20), + } + + +def dev_pipeline_parallel_tasks_from_json(text: str, read_paths: list[str], prompt: str) -> list[dict[str, Any]]: + stripped = text.strip() + if not stripped or stripped[0] not in "[{": + return [] + try: + payload = json.loads(stripped) + except json.JSONDecodeError: + return [] + if isinstance(payload, dict): + raw_items = payload.get("tasks") or payload.get("workstreams") or payload.get("parallel_workstreams") or [] + else: + raw_items = payload + if not isinstance(raw_items, list): + return [] + tasks = [] + for index, item in enumerate(raw_items, start=1): + if not isinstance(item, dict): + continue + task = dev_pipeline_parallel_task_from_mapping(item, index, read_paths, prompt) + if task: + tasks.append(task) + return tasks + + +def dev_pipeline_parallel_task_specs( + project: dict[str, Any], + template: dict[str, Any], + trigger: dict[str, Any], + target_paths: list[str], + prompt: str, +) -> list[dict[str, Any]]: + read_paths = [str(value) for value in project.get("read_paths", []) if isinstance(value, str) and value.strip()] + text = dev_pipeline_parallel_input_text(template, trigger) + tasks = dev_pipeline_parallel_tasks_from_json(text, read_paths, prompt) + if tasks: + return tasks + result = [] + for index, path in enumerate(target_paths, start=1): + result.append( + { + "id": dev_pipeline_workset_slug(f"parallel-{index}-{Path(path).stem}") or f"parallel-{index}", + "worker_id": f"api-parallel-worker-{index}", + "task": f"Implement exclusive workstream for {path}"[:240], + "description": ( + "Implement this independent parallel workstream using only its exclusive write path. " + "Return complete UTF-8 contents for every changed owned path using patch_proposal.v1. " + "Do not edit files outside write_paths. " + f"Exclusive write path: {path}\n\nOverall prompt:\n{prompt}" + ), + "write_paths": [path], + "read_paths": read_paths, + "routes": [], + "depends_on": [], + "api_profile": DEV_PIPELINE_DELIVERY_API_PROFILE, + "output_schema": DEV_PIPELINE_DELIVERY_OUTPUT_SCHEMA, + "cost_usd_estimate": 0.20, + } + ) + return result + + +def dev_pipeline_dirty_target_errors(paths: list[str]) -> list[str]: + if not paths: + return [] + try: + proc = subprocess.run( + ["git", "status", "--porcelain", "--", *paths], + cwd=ROOT_DIR, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except FileNotFoundError: + return ["git is unavailable; dirty target-path checks cannot run"] + if proc.returncode != 0: + return [proc.stderr.strip() or "git status failed for target paths"] + dirty = [line.strip() for line in proc.stdout.splitlines() if line.strip()] + if not dirty: + return [] + errors: list[str] = [] + for line in dirty: + status = line[:2].strip() or "modified" + path = line[3:].strip() if len(line) > 3 else line + reason = "untracked" if "?" in status else "modified" + errors.append( + f"Target path is already {reason}: {path}. Use a fresh target path or commit/remove the existing file before rerunning." + ) + return errors + + +def dev_pipeline_delivery_workset( + root: Path, + project: dict[str, Any], + template: dict[str, Any], + trigger: dict[str, Any], + run_id: str, + target_paths: list[str], + budget_usd: float, + max_budget_usd: float, + runtime_config: dict[str, Any] | None = None, +) -> tuple[str, dict[str, Any]]: + prompt = dev_pipeline_delivery_prompt(project, template, trigger) + issue_id = str(trigger.get("issue_id") or "").strip() + subject = str(trigger.get("issue_subject") or "").strip() + prompt_lines = prompt.splitlines() + task_text = subject or (prompt_lines[0] if prompt_lines else "Implement requested repo change") + read_paths = [str(value) for value in project.get("read_paths", []) if isinstance(value, str) and value.strip()] + workset_id = dev_pipeline_slug(f"delivery-{template.get('id')}-{project.get('id')}-{run_id}", "delivery") + is_parallel = dev_pipeline_template_is_parallel_workset(template) + runtime_config = runtime_config if isinstance(runtime_config, dict) else {} + max_parallel = dev_pipeline_int_value(runtime_config.get("max_parallel"), dev_pipeline_parallel_max_parallel(template, trigger)) if is_parallel else 1 + if is_parallel: + tasks = dev_pipeline_parallel_task_specs(project, template, trigger, target_paths, prompt) + if str(runtime_config.get("runtime") or "") == "fixture": + for index, task in enumerate(tasks, start=1): + if isinstance(task, dict) and str(task.get("worker_id") or "").startswith("api-parallel-worker"): + task["worker_id"] = f"fixture-parallel-worker-{index}" + else: + description = ( + "Implement the requested bounded repo change using only the declared write paths. " + "Return complete UTF-8 contents for every changed owned path using patch_proposal.v1. " + "Do not edit files outside write_paths. Keep the change minimal and runnable. " + f"Budget target: ${budget_usd:.2f}; hard cap: ${max_budget_usd:.2f}. " + f"Issue: {issue_id or 'manual'}.\n\nPrompt:\n{prompt}" + ) + tasks = [ + { + "id": "delivery", + "worker_id": "api-delivery-worker", + "task": task_text[:240], + "description": description, + "write_paths": target_paths, + "read_paths": read_paths, + "routes": [], + "depends_on": [], + "api_profile": DEV_PIPELINE_DELIVERY_API_PROFILE, + "output_schema": DEV_PIPELINE_DELIVERY_OUTPUT_SCHEMA, + "cost_usd_estimate": 0.20, + } + ] + workset = { + "schema_version": "cento.workset.v1", + "id": workset_id, + "mode": "fast", + "max_parallel": max_parallel, + "read_paths": read_paths, + "execution_model": "parallel" if is_parallel else "single", + "integration": "sequential", + "runtime": str(runtime_config.get("runtime") or "api-openai"), + "apply_mode": str(runtime_config.get("apply_mode") or "apply"), + "validation": str(runtime_config.get("validation") or "smoke"), + "integration_model_policy": { + "mode": "deterministic-first", + "integrator": str(runtime_config.get("integrator") or "sequential"), + "fallback": "only-if-needed", + "model_ceiling": DEV_PIPELINE_INTEGRATION_MODEL_CEILING, + "profile": "api-mini-integrator", + }, + "issue_id": issue_id, + "tasks": tasks, + } + rel_path = f"execution/worksets/{run_id}.json" + workset_path = dev_pipeline_root_path(root, rel_path) + write_json_path(workset_path, workset) + return dev_pipeline_relative(workset_path), workset + + +def dev_pipeline_delivery_readiness(target_paths: list[str], workset_rel: str, runtime_config: dict[str, Any] | None = None) -> tuple[list[str], dict[str, Any]]: + runtime_config = runtime_config if isinstance(runtime_config, dict) else {} + runtime = str(runtime_config.get("runtime") or "api-openai") + apply_enabled = bool(runtime_config.get("apply_enabled", True)) + config, config_errors = dev_pipeline_api_worker_config() if runtime == "api-openai" else ({}, []) + errors: list[str] = [] + if not target_paths: + errors.append("No explicit repo-relative write path was found in the prompt or input contract") + if not workset_rel and target_paths: + errors.append("Workset manifest was not written") + errors.extend(config_errors) + if apply_enabled: + errors.extend(dev_pipeline_dirty_target_errors(target_paths)) + openai_config = config.get("openai") if isinstance(config.get("openai"), dict) else {} + profiles = config.get("profiles") if isinstance(config.get("profiles"), dict) else {} + profile = profiles.get(DEV_PIPELINE_DELIVERY_API_PROFILE) if isinstance(profiles.get(DEV_PIPELINE_DELIVERY_API_PROFILE), dict) else {} + return errors, { + "runtime": runtime, + "api_profile": DEV_PIPELINE_DELIVERY_API_PROFILE, + "model": dev_pipeline_env_reference(str(profile.get("model") or "")), + "budget_usd": runtime_config.get("budget_usd", DEV_PIPELINE_DELIVERY_BUDGET_USD), + "max_budget_usd": runtime_config.get("max_budget_usd", DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD), + "apply_mode": runtime_config.get("apply_mode", "apply"), + "configured_budget_max_usd": openai_config.get("budget_usd_max"), + } + + +def dev_pipeline_delivery_seed_steps(workset_rel: str, status: str, input_ready: bool = True, workset: dict[str, Any] | None = None) -> list[dict[str, Any]]: + base_status = "completed" if input_ready else "blocked" + workset = workset if isinstance(workset, dict) else {} + tasks = [item for item in workset.get("tasks", []) if isinstance(item, dict)] + is_parallel = int(workset.get("max_parallel") or 1) > 1 or len(tasks) > 1 + if is_parallel: + queued_or_blocked = "queued" if status == "running" else "blocked" + return [ + {"id": "resolve-parallel-inputs", "title": "Resolve contract and exclusive write paths", "status": base_status, "duration": "0s", "duration_seconds": 0, "file": "execution/execution_run.json"}, + {"id": "write-parallel-workset", "title": "Write parallel workset manifest", "status": "completed" if workset_rel else "blocked", "duration": "0s", "duration_seconds": 0, "file": workset_rel}, + *[ + { + "id": f"parallel-worker-{str(task.get('id') or index)}", + "title": f"Worker: {str(task.get('task') or task.get('id') or f'parallel {index}')}", + "status": queued_or_blocked, + "duration": "0s", + "duration_seconds": 0, + "file": ", ".join(str(path) for path in task.get("write_paths", []) if isinstance(path, str)), + "stage": "execution", + } + for index, task in enumerate(tasks, start=1) + ], + {"id": "collect-worker-artifacts", "title": "Collect worker artifacts", "status": queued_or_blocked, "duration": "0s", "duration_seconds": 0, "file": ""}, + {"id": "integrate-sequentially", "title": "Integrate patches sequentially", "status": queued_or_blocked, "duration": "0s", "duration_seconds": 0, "file": ""}, + {"id": "run-parallel-validation", "title": "Run parallel validation gates", "status": queued_or_blocked, "duration": "0s", "duration_seconds": 0, "file": ""}, + {"id": "collect-parallel-evidence", "title": "Collect receipts, cost, and evidence", "status": queued_or_blocked, "duration": "0s", "duration_seconds": 0, "file": ""}, + ] + return [ + {"id": "resolve-prompt", "title": "Resolve prompt and target paths", "status": base_status, "duration": "0s", "duration_seconds": 0, "file": "execution/execution_run.json"}, + {"id": "write-workset", "title": "Write executable workset", "status": "completed" if workset_rel else "blocked", "duration": "0s", "duration_seconds": 0, "file": workset_rel}, + {"id": "api-worker", "title": "OpenAI patch proposal worker", "status": "queued" if status == "running" else "blocked", "duration": "0s", "duration_seconds": 0, "file": ""}, + {"id": "materialize-patch", "title": "Materialize patch bundle", "status": "queued" if status == "running" else "blocked", "duration": "0s", "duration_seconds": 0, "file": ""}, + {"id": "integrate-sequential", "title": "Integrate patch sequentially", "status": "queued" if status == "running" else "blocked", "duration": "0s", "duration_seconds": 0, "file": ""}, + {"id": "apply-worktree", "title": "Apply accepted change to worktree", "status": "queued" if status == "running" else "blocked", "duration": "0s", "duration_seconds": 0, "file": ""}, + {"id": "collect-receipts", "title": "Collect receipts, cost, and evidence", "status": "queued" if status == "running" else "blocked", "duration": "0s", "duration_seconds": 0, "file": ""}, + ] + + +def dev_pipeline_delivery_stage_payloads(started: datetime, status: str, finished: datetime | None = None, input_ready: bool = True) -> list[dict[str, Any]]: + final = finished or started + is_blocked = status == "blocked" + intake_status = "completed" if input_ready else "blocked" + return [ + {"id": "input", "started_at": started.isoformat(), "finished_at": final.isoformat(), "status": intake_status}, + {"id": "repo", "started_at": started.isoformat(), "finished_at": final.isoformat(), "status": intake_status}, + {"id": "blueprint", "started_at": started.isoformat(), "finished_at": final.isoformat(), "status": intake_status}, + {"id": "factory", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": status}, + {"id": "validation", "started_at": final.isoformat() if finished else "", "finished_at": final.isoformat() if finished else "", "status": "completed" if status == "completed" else ("blocked" if is_blocked else "queued")}, + {"id": "handoff", "started_at": final.isoformat() if finished else "", "finished_at": final.isoformat() if finished else "", "status": "completed" if status == "completed" else ("blocked" if is_blocked else "queued")}, + ] + + +def dev_pipeline_hard_proreq_artifacts(run_id: str) -> list[dict[str, Any]]: + rels = [ + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/operator_intake.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/mini_cento_context.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/ui_screenshot_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/existing_ui_reference.png", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/existing_ui_reference_square.png", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/image_generation_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/image_generation_response.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/generated_integrator_screenshot.png", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/pro_output_schema.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/pro_backend_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/proreq_light_codex_prompt.md", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/proreq_light_output_schema.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/proreq_light_codex_command.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/proreq_light_codex_stdout.txt", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/proreq_light_codex_stderr.txt", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/proreq_light_codex_response.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/pro_backend_response.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/pro_backend_error.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/pro_backend_plan.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/story_index.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/parallel_patch_workset.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/manifest_integration_policy.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/backend_work_manifest.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/integration_plan.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/validation_plan.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_check_stdout.txt", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_check_stderr.txt", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_workset_stdout.txt", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_workset_stderr.txt", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_delivery.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_validation.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_evidence.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_evidence.md", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_incident.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_incident.md", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/delivery/{run_id}/closed-loop.stdout.log", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/delivery/{run_id}/closed-loop.stderr.log", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/hard_proreq_evidence.json", + ] + return [ + { + "name": Path(rel).name, + "path": rel, + "exists": (ROOT_DIR / rel).exists(), + "size": file_size_label(ROOT_DIR / rel), + } + for rel in rels + ] + + +def dev_pipeline_hard_proreq_stage_payloads(started: datetime, status: str, finished: datetime | None = None) -> list[dict[str, Any]]: + final = finished or started + return [ + {"id": "input", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "running"}, + {"id": "repo", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "queued"}, + {"id": "blueprint", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "queued"}, + {"id": "factory", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": status}, + {"id": "validation", "started_at": final.isoformat() if finished else "", "finished_at": final.isoformat() if finished else "", "status": "completed" if status == "completed" else ("blocked" if status == "blocked" else "queued")}, + {"id": "handoff", "started_at": final.isoformat() if finished else "", "finished_at": final.isoformat() if finished else "", "status": "completed" if status == "completed" else ("blocked" if status == "blocked" else "queued")}, + ] + + +def dev_pipeline_multipipeline_artifacts(run_id: str) -> list[dict[str, Any]]: + rels = [ + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/operator_intake.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/multipipeline_schedule.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/pass_01_proreq_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/pass_01_guidance.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/pass_02_proreq_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/pass_02_guidance.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/pass_03_proreq_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/pass_03_guidance.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/pass_04_proreq_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/pass_04_guidance.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/ui_screenshot_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/chatgpt_pro_request.json", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/chain_roadmap.md", + f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/multipipeline/{run_id}/multipipeline_evidence.json", + ] + return [ + { + "name": Path(rel).name, + "path": rel, + "exists": (ROOT_DIR / rel).exists(), + "size": file_size_label(ROOT_DIR / rel), + } + for rel in rels + ] + + +def dev_pipeline_multipipeline_stage_payloads(started: datetime, status: str, finished: datetime | None = None) -> list[dict[str, Any]]: + final = finished or started + return [ + {"id": "input", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "running"}, + {"id": "repo", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "queued"}, + {"id": "blueprint", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "queued"}, + {"id": "factory", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": status}, + {"id": "validation", "started_at": final.isoformat() if finished else "", "finished_at": final.isoformat() if finished else "", "status": "completed" if status == "completed" else ("blocked" if status == "blocked" else "queued")}, + {"id": "handoff", "started_at": final.isoformat() if finished else "", "finished_at": final.isoformat() if finished else "", "status": "completed" if status == "completed" else ("blocked" if status == "blocked" else "queued")}, + ] + + +def dev_pipeline_patch_swarm_artifacts(run_id: str) -> list[dict[str, Any]]: + base = f"workspace/runs/parallel-delivery/patch-swarm/{run_id}" + rels = [ + f"{base}/patch_swarm_manifest.json", + f"{base}/proreq_execution_manifest.json", + f"{base}/candidate_index.json", + f"{base}/dedupe_clusters.json", + f"{base}/ranking.json", + f"{base}/cost_ledger.json", + f"{base}/patch_swarm_receipt.json", + f"{base}/integration_execution/integration_execution.json", + f"{base}/safe_integrator_handoff.json", + f"{base}/validation_summary.json", + f"{base}/ui_state.json", + f"{base}/decision_report.md", + "workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/patch-swarm/latest_ui_state.json", + ] + return [ + { + "name": Path(rel).name, + "path": rel, + "exists": (ROOT_DIR / rel).exists(), + "size": file_size_label(ROOT_DIR / rel), + } + for rel in rels + ] + + +def dev_pipeline_patch_swarm_stage_payloads(started: datetime, status: str, finished: datetime | None = None) -> list[dict[str, Any]]: + final = finished or started + return [ + {"id": "input", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "running"}, + {"id": "repo", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "queued"}, + {"id": "blueprint", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": "completed" if finished else "queued"}, + {"id": "factory", "started_at": started.isoformat(), "finished_at": final.isoformat() if finished else "", "status": status}, + {"id": "validation", "started_at": final.isoformat() if finished else "", "finished_at": final.isoformat() if finished else "", "status": "completed" if status == "completed" else ("blocked" if status == "blocked" else "queued")}, + {"id": "handoff", "started_at": final.isoformat() if finished else "", "finished_at": final.isoformat() if finished else "", "status": "completed" if status == "completed" else ("blocked" if status == "blocked" else "queued")}, + ] + + +def dev_pipeline_patch_swarm_config(template: dict[str, Any], trigger: dict[str, Any]) -> dict[str, Any]: + parts = [str(trigger.get("prompt") or ""), str(trigger.get("issue_subject") or "")] + for item in template.get("required_inputs", []): + if not isinstance(item, dict): + continue + if str(item.get("id") or "") not in {"patch-swarm-objective", "patch-swarm-provider-policy"}: + continue + parts.extend([str(item.get("answer") or ""), str(item.get("answer_notes") or "")]) + parts.extend(str(value) for value in item.get("answer_values", []) if isinstance(value, str)) + text = "\n".join(part for part in parts if part) + config: dict[str, str] = {} + for line in text.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + key = re.sub(r"[^a-z0-9_]+", "_", key.strip().lower()).strip("_") + value = value.strip() + if key and value: + config[key] = value + return { + "candidate_target": dev_pipeline_int_value(config.get("candidate_target"), 100, minimum=100, maximum=500), + "max_parallel_agents": dev_pipeline_int_value(config.get("max_parallel_agents"), 5, minimum=1, maximum=20), + "providers": config.get("providers") or "codex-exec,claude-code,api-openai", + "live": str(config.get("mode") or "fixture").lower() in {"live", "real"}, + } + + +def dev_pipeline_seed_patch_swarm_execution( + root: Path, + manifest: dict[str, Any], + project: dict[str, Any], + template: dict[str, Any], + trigger: dict[str, Any], +) -> dict[str, Any]: + execution_manifest_rel = str(template.get("execution_manifest") or "execution/patch_swarm_execution_manifest.json") + execution_manifest = {} + execution_steps = dev_pipeline_template_factory_steps(template) + if not execution_steps: + raise AgentWorkAppError("No Patch Swarm execution steps are configured") + run_started = datetime.now(timezone.utc) + run_id = f"patch-swarm-ui-{run_started.strftime('%Y%m%dT%H%M%S%fZ')}" + prompt = dev_pipeline_delivery_prompt(project, template, trigger) + config = dev_pipeline_patch_swarm_config(template, trigger) + inputs = dev_pipeline_template_required_inputs(template) + for item in inputs: + if str(item.get("id") or "") == "patch-swarm-objective" and prompt: + item["status"] = "provided" + item["answer"] = prompt + item["answer_notes"] = "Captured from Run Pipeline prompt or structured Patch Swarm answers." + item["provided_at"] = run_started.isoformat() + template["required_inputs"] = dev_pipeline_write_input_manifests(root, manifest, project, template, inputs) + queued_steps = [ + { + **step, + "id": str(step.get("id") or f"step-{index}"), + "title": str(step.get("title") or step.get("id") or f"step-{index}"), + "status": "queued", + "exit_code": None, + "duration": "0s", + "duration_seconds": 0, + "started_at": "", + "finished_at": "", + "stdout_tail": "", + "stderr_tail": "", + } + for index, step in enumerate(execution_steps, start=1) + ] + run_payload = { + "schema_version": "cento.execution_run.v1", + "source": "cento-patch-swarm", + "run_id": run_id, + "pipeline": f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}", + "status": "running", + "started_at": run_started.isoformat(), + "finished_at": "", + "duration_seconds": 0, + "triggered_by": str(trigger.get("triggered_by") or "pipeline-run-api"), + "issue_id": str(trigger.get("issue_id") or ""), + "issue_subject": str(trigger.get("issue_subject") or ""), + "prompt": prompt, + "inputs": inputs, + "runtime": "cento parallel-delivery patch-swarm e2e fixture; provider adapters codex-exec, claude-code, api-openai", + "apply_mode": "safe-integrator-handoff-only", + "candidate_target": config["candidate_target"], + "max_parallel_agents": config["max_parallel_agents"], + "providers": config["providers"], + "budget_usd": 0.0, + "max_budget_usd": 20.0, + "stages": dev_pipeline_patch_swarm_stage_payloads(run_started, "running"), + "steps": queued_steps, + "logs": [ + { + "timestamp": run_started.isoformat(), + "stage": "pipeline", + "source": "patch-swarm", + "message": f"Patch Swarm started: {config['candidate_target']} candidates across ten ProReq lanes and one dedicated integrator", + } + ], + "artifacts": dev_pipeline_patch_swarm_artifacts(run_id), + "facts": [ + {"label": "Engine", "value": "cento parallel-delivery patch-swarm"}, + {"label": "Providers", "value": config["providers"]}, + {"label": "Candidates", "value": str(config["candidate_target"])}, + {"label": "Max agents", "value": str(config["max_parallel_agents"])}, + {"label": "Integrator", "value": "one dedicated serialized Safe Integrator handoff"}, + {"label": "Budget", "value": "$0.00 fixture path / live API opt-in"}, + ], + "written_at": datetime.now(timezone.utc).isoformat(), + } + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + return run_payload + + +def dev_pipeline_seed_hard_proreq_execution( + root: Path, + manifest: dict[str, Any], + project: dict[str, Any], + template: dict[str, Any], + trigger: dict[str, Any], +) -> dict[str, Any]: + execution_manifest_rel = str(template.get("execution_manifest") or "execution/execution_manifest.json") + execution_manifest = {} + execution_steps = dev_pipeline_template_factory_steps(template) + if not execution_steps: + raise AgentWorkAppError("No hard proreq execution steps are configured") + run_started = datetime.now(timezone.utc) + run_id = f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}-{run_started.strftime('%Y%m%dT%H%M%S%fZ')}" + prompt = dev_pipeline_delivery_prompt(project, template, trigger) + is_light = str(template.get("id") or "") == PROREQ_LIGHT_TEMPLATE_ID + requested_delivery_mode = str(trigger.get("delivery_mode") or "").strip() + delivery_mode = requested_delivery_mode if requested_delivery_mode else ("closed-loop" if is_light else "plan-only") + inputs = dev_pipeline_template_required_inputs(template) + for item in inputs: + if str(item.get("id") or "") == "operator-thoughts" and prompt: + item["status"] = "provided" + item["answer"] = prompt + item["answer_notes"] = "Captured from Run Pipeline prompt or manual rerun context." + item["provided_at"] = run_started.isoformat() + template["required_inputs"] = dev_pipeline_write_input_manifests(root, manifest, project, template, inputs) + queued_steps: list[dict[str, Any]] = [] + for index, step in enumerate(execution_steps, start=1): + step_id = str(step.get("id") or f"step-{index}") + command = dev_pipeline_execution_command_for_step(step_id) + queued_steps.append( + { + **step, + "id": step_id, + "title": str(step.get("title") or step_id), + "status": "queued", + "command": shlex.join(command), + "exit_code": None, + "duration": "0s", + "duration_seconds": 0, + "started_at": "", + "finished_at": "", + "stdout_tail": "", + "stderr_tail": "", + } + ) + run_payload = { + "schema_version": "cento.execution_run.v1", + "source": "cento-proreq-light-codex" if is_light else "cento-hard-proreq-pro", + "run_id": run_id, + "pipeline": f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}", + "status": "running", + "started_at": run_started.isoformat(), + "finished_at": "", + "duration_seconds": 0, + "triggered_by": str(trigger.get("triggered_by") or "prompt-router"), + "issue_id": str(trigger.get("issue_id") or ""), + "issue_subject": str(trigger.get("issue_subject") or ""), + "prompt": prompt, + "inputs": inputs, + "runtime": ( + "cento-native + Codex Exec ProReq-light + 10-story split + closed-loop Codex patch delivery" + if is_light + else f"cento-native + 10-story split + integration model ceiling {DEV_PIPELINE_INTEGRATION_MODEL_CEILING}" + ), + "delivery_mode": delivery_mode, + "apply_mode": "closed-loop-clean-apply" if is_light and delivery_mode == "closed-loop" else "backend-plan-first", + "budget_usd": DEV_PIPELINE_DELIVERY_BUDGET_USD, + "max_budget_usd": DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD, + "stages": dev_pipeline_hard_proreq_stage_payloads(run_started, "running"), + "steps": queued_steps, + "logs": [ + { + "timestamp": run_started.isoformat(), + "stage": "pipeline", + "source": "proreq-light" if is_light else "hard-proreq", + "message": ( + f"ProReq-light run started; Codex Exec will simulate the ChatGPT Pro planning lane and delivery_mode={delivery_mode}" + if is_light + else "Hard proreq run started; GPT pro backend planning request will use strict JSON Schema and frontend screenshot flow stays muted" + ), + } + ], + "artifacts": dev_pipeline_hard_proreq_artifacts(run_id), + "facts": [ + {"label": "Engine", "value": "cento proreq light" if is_light else "cento hard proreq"}, + {"label": "Runtime", "value": "codex exec as ChatGPT Pro simulator + local Codex workers" if is_light else "cento-native + ten story manifests"}, + {"label": "Delivery", "value": delivery_mode if is_light else "plan-only"}, + {"label": "Model ceiling", "value": f"integration fallback at most {DEV_PIPELINE_INTEGRATION_MODEL_CEILING}"}, + {"label": "Schema", "value": "codex exec --output-schema with hard proreq JSON schema" if is_light else "strict Responses JSON Schema + codex --output-schema"}, + {"label": "Frontend lane", "value": "muted separate screenshot flow"}, + {"label": "Budget", "value": "$0.00 metered OpenAI API / Codex Exec route" if is_light else f"${DEV_PIPELINE_DELIVERY_BUDGET_USD:.2f} target / ${DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD:.2f} cap"}, + ], + "written_at": datetime.now(timezone.utc).isoformat(), + } + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + return run_payload + + +def dev_pipeline_seed_multipipeline_execution( + root: Path, + manifest: dict[str, Any], + project: dict[str, Any], + template: dict[str, Any], + trigger: dict[str, Any], +) -> dict[str, Any]: + execution_manifest_rel = str(template.get("execution_manifest") or "execution/multipipeline_execution_manifest.json") + execution_manifest = {} + execution_steps = dev_pipeline_template_factory_steps(template) + if not execution_steps: + raise AgentWorkAppError("No multipipeline ProReq execution steps are configured") + run_started = datetime.now(timezone.utc) + run_id = f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}-{run_started.strftime('%Y%m%dT%H%M%S%fZ')}" + prompt = dev_pipeline_delivery_prompt(project, template, trigger) + inputs = dev_pipeline_template_required_inputs(template) + for item in inputs: + if str(item.get("id") or "") == "multipipeline-objective" and prompt: + item["status"] = "provided" + item["answer"] = prompt + item["answer_notes"] = "Captured from Run Pipeline prompt or structured objective answers." + item["provided_at"] = run_started.isoformat() + template["required_inputs"] = dev_pipeline_write_input_manifests(root, manifest, project, template, inputs) + queued_steps: list[dict[str, Any]] = [] + for index, step in enumerate(execution_steps, start=1): + step_id = str(step.get("id") or f"step-{index}") + command = dev_pipeline_execution_command_for_step(step_id) + queued_steps.append( + { + **step, + "id": step_id, + "title": str(step.get("title") or step_id), + "status": "queued", + "command": shlex.join(command), + "exit_code": None, + "duration": "0s", + "duration_seconds": 0, + "started_at": "", + "finished_at": "", + "stdout_tail": "", + "stderr_tail": "", + } + ) + run_payload = { + "schema_version": "cento.execution_run.v1", + "source": "cento-multipipeline-proreq-chain", + "run_id": run_id, + "pipeline": f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}", + "status": "running", + "started_at": run_started.isoformat(), + "finished_at": "", + "duration_seconds": 0, + "triggered_by": str(trigger.get("triggered_by") or "pipeline-run-api"), + "issue_id": str(trigger.get("issue_id") or ""), + "issue_subject": str(trigger.get("issue_subject") or ""), + "prompt": prompt, + "inputs": inputs, + "runtime": "cento-native + four sequential ProReq request passes + request-only Pro/image lanes", + "apply_mode": "request-artifacts-only", + "budget_usd": 0.0, + "max_budget_usd": 0.0, + "stages": dev_pipeline_multipipeline_stage_payloads(run_started, "running"), + "steps": queued_steps, + "logs": [ + { + "timestamp": run_started.isoformat(), + "stage": "pipeline", + "source": "multipipeline-proreq", + "message": "Multipipeline ProReq chain started; four ordered ProReq request passes will feed guidance forward without live Pro/image dispatch by default", + } + ], + "artifacts": dev_pipeline_multipipeline_artifacts(run_id), + "facts": [ + {"label": "Engine", "value": "cento multipipeline proreq"}, + {"label": "Runtime", "value": "4 sequential hard-proreq request passes"}, + {"label": "Model policy", "value": "request artifacts only unless live Pro/image is explicitly enabled"}, + {"label": "Schema", "value": "cento.multipipeline_proreq_chain.v1 + pipeline_run_request.v1"}, + {"label": "Frontend lane", "value": "muted UI screenshot request artifact"}, + {"label": "Budget", "value": "$0.00 deterministic / live calls opt-in"}, + ], + "written_at": datetime.now(timezone.utc).isoformat(), + } + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + return run_payload + + +def dev_pipeline_seed_execution_e2e( + root: Path, + manifest: dict[str, Any], + project: dict[str, Any], + template: dict[str, Any], + trigger: dict[str, Any] | None = None, +) -> dict[str, Any]: + if str(template.get("id") or "") in {HARD_PROREQ_TEMPLATE_ID, PROREQ_LIGHT_TEMPLATE_ID}: + return dev_pipeline_seed_hard_proreq_execution(root, manifest, project, template, trigger if isinstance(trigger, dict) else {}) + if str(template.get("id") or "") == MULTIPIPELINE_TEMPLATE_ID: + return dev_pipeline_seed_multipipeline_execution(root, manifest, project, template, trigger if isinstance(trigger, dict) else {}) + if str(template.get("id") or "") == PATCH_SWARM_TEMPLATE_ID: + return dev_pipeline_seed_patch_swarm_execution(root, manifest, project, template, trigger if isinstance(trigger, dict) else {}) + execution_manifest_rel, execution_manifest, _execution_steps = dev_pipeline_execution_steps(root, template) + trigger = trigger if isinstance(trigger, dict) else {} + run_started = datetime.now(timezone.utc) + run_id = f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}-{run_started.strftime('%Y%m%dT%H%M%S%fZ')}" + is_parallel = dev_pipeline_template_is_parallel_workset(template) + runtime_config = dev_pipeline_parallel_runtime_config(template, trigger) if is_parallel else { + "runtime": "api-openai", + "validation": "smoke", + "apply_mode": "apply", + "apply_enabled": True, + "budget_usd": DEV_PIPELINE_DELIVERY_BUDGET_USD, + "max_budget_usd": DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD, + } + target_paths = dev_pipeline_delivery_target_paths(project, template, trigger) + if is_parallel and not target_paths: + target_paths = dev_pipeline_parallel_auto_target_paths(project, run_id, runtime_config) + workset_rel = "" + if target_paths: + workset_rel, workset = dev_pipeline_delivery_workset( + root, + project, + template, + trigger, + run_id, + target_paths, + float(runtime_config.get("budget_usd") or DEV_PIPELINE_DELIVERY_BUDGET_USD), + float(runtime_config.get("max_budget_usd") or DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD), + runtime_config, + ) + else: + workset = {} + readiness_errors, readiness = dev_pipeline_delivery_readiness(target_paths, workset_rel, runtime_config) + run_status = "blocked" if readiness_errors else "running" + run_finished = run_started if readiness_errors else None + prompt = dev_pipeline_delivery_prompt(project, template, trigger) + task_count = len([item for item in workset.get("tasks", []) if isinstance(item, dict)]) + max_parallel = int(workset.get("max_parallel") or 1) if isinstance(workset, dict) else 1 + runtime = str(runtime_config.get("runtime") or "api-openai") + apply_mode = str(runtime_config.get("apply_mode") or "apply") + validation_mode = str(runtime_config.get("validation") or "smoke") + run_payload = { + "schema_version": "cento.execution_run.v1", + "source": f"cento-workset-{runtime}", + "run_id": run_id, + "pipeline": f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}", + "status": run_status, + "started_at": run_started.isoformat(), + "finished_at": run_finished.isoformat() if run_finished else "", + "duration_seconds": 0, + "triggered_by": str(trigger.get("triggered_by") or "prompt-router"), + "issue_id": str(trigger.get("issue_id") or ""), + "issue_subject": str(trigger.get("issue_subject") or ""), + "prompt": prompt, + "target_paths": target_paths, + "workset_manifest": workset_rel, + "workset_id": str(workset.get("id") or ""), + "workset_max_parallel": max_parallel, + "workset_task_count": task_count, + "execution_model": "parallel" if is_parallel else "single", + "runtime": runtime, + "apply_mode": apply_mode, + "validation_mode": validation_mode, + "budget_usd": float(runtime_config.get("budget_usd") or 0.0), + "max_budget_usd": float(runtime_config.get("max_budget_usd") or 0.0), + "readiness": readiness, + "readiness_errors": readiness_errors, + "stages": dev_pipeline_delivery_stage_payloads(run_started, run_status, run_finished, bool(target_paths)), + "steps": dev_pipeline_delivery_seed_steps(workset_rel, run_status, bool(target_paths), workset), + "logs": [ + { + "timestamp": run_started.isoformat(), + "stage": "pipeline", + "source": "delivery", + "message": "Workset delivery requested", + }, + *[ + { + "timestamp": run_started.isoformat(), + "stage": "pipeline", + "source": "readiness", + "message": error, + } + for error in readiness_errors + ], + ], + "artifacts": [ + {"name": "workset.json", "path": workset_rel, "exists": bool(workset_rel), "size": file_size_label(ROOT_DIR / workset_rel) if workset_rel else "missing"}, + {"name": "execution_run.json", "path": dev_pipeline_relative(root / "execution" / "execution_run.json"), "exists": True, "size": "pending"}, + ], + "facts": [ + {"label": "Engine", "value": "cento workset execute"}, + {"label": "Runtime", "value": runtime}, + {"label": "Apply", "value": "sequential integrator" if apply_mode == "apply" else "dry-run integrator"}, + {"label": "Integration model ceiling", "value": f"{DEV_PIPELINE_INTEGRATION_MODEL_CEILING} only if deterministic integration needs review"}, + {"label": "Parallel workers", "value": str(task_count) if is_parallel else "1"}, + {"label": "Max parallel", "value": str(max_parallel)}, + {"label": "Budget", "value": f"${float(runtime_config.get('budget_usd') or 0.0):.2f} target / ${float(runtime_config.get('max_budget_usd') or 0.0):.2f} cap"}, + {"label": "Target paths", "value": ", ".join(target_paths) if target_paths else "missing"}, + ], + "written_at": datetime.now(timezone.utc).isoformat(), + } + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + return run_payload + + +def dev_pipeline_run_input_allowed_fields(kind: str, source: str) -> set[str]: + common = {"id", "kind", "source"} + if source == "auto": + if kind == "image": + return common | {"image_refs", "image_notes", "answer_notes"} + return common + fields = { + "text": {"answer", "answer_notes"}, + "questionnaire": {"answer", "answers", "answer_notes"}, + "path": {"paths", "answer_notes"}, + "image": {"image_refs", "image_notes", "answer_notes"}, + "details": {"answer", "answer_notes"}, + "evidence": {"artifacts", "evidence_policy", "answer_notes"}, + } + return common | fields.get(kind, set()) + + +def dev_pipeline_run_input_has_user_value(kind: str, item: dict[str, Any]) -> bool: + if kind in {"text", "questionnaire", "details"}: + if str(item.get("answer") or "").strip(): + return True + answers = item.get("answers") + if isinstance(answers, dict): + return any(str(value).strip() for value in answers.values()) + if isinstance(answers, list): + return any(str(value).strip() for value in answers) + return False + if kind == "path": + return bool(dev_pipeline_text_list(item.get("paths"), [])) + if kind == "image": + return bool(dev_pipeline_text_list(item.get("image_refs"), [])) + if kind == "evidence": + return bool(dev_pipeline_text_list(item.get("artifacts"), [])) + return False + + +def dev_pipeline_run_input_answer(kind: str, item: dict[str, Any]) -> tuple[str, list[str], str]: + notes = dev_pipeline_text(item.get("answer_notes"), "") + if kind == "questionnaire": + answer = dev_pipeline_text(item.get("answer"), "") + values: list[str] = [] + answers = item.get("answers") + if isinstance(answers, dict): + values = [f"{key}: {value}" for key, value in answers.items() if str(value).strip()] + elif isinstance(answers, list): + values = [str(value) for value in answers if str(value).strip()] + if not answer and values: + answer = "\n".join(values) + return answer, values, notes + if kind in {"text", "details"}: + return dev_pipeline_text(item.get("answer"), ""), [], notes + if kind == "path": + values = dev_pipeline_text_list(item.get("paths"), []) + return "\n".join(values), values, notes + if kind == "image": + values = dev_pipeline_text_list(item.get("image_refs"), []) + image_notes = dev_pipeline_text(item.get("image_notes"), "") + return image_notes, values, notes + if kind == "evidence": + values = dev_pipeline_text_list(item.get("artifacts"), []) + evidence_policy = dev_pipeline_text(item.get("evidence_policy"), "") + return evidence_policy, values, notes + return "", [], notes + + +def dev_pipeline_validate_pipeline_run_payload(payload: dict[str, Any]) -> None: + allowed = {"schema_version", "project_id", "template_id", "inputs", "delivery_mode"} + extras = sorted(set(payload) - allowed) + if extras: + raise AgentWorkAppError(f"Unexpected pipeline run field(s): {', '.join(extras)}") + if str(payload.get("schema_version") or "") != PIPELINE_RUN_SCHEMA_VERSION: + raise AgentWorkAppError(f"schema_version must be {PIPELINE_RUN_SCHEMA_VERSION}") + if not str(payload.get("project_id") or "").strip(): + raise AgentWorkAppError("project_id is required") + if not str(payload.get("template_id") or "").strip(): + raise AgentWorkAppError("template_id is required") + if not isinstance(payload.get("inputs"), list): + raise AgentWorkAppError("inputs must be an ordered array") + delivery_mode = str(payload.get("delivery_mode") or "").strip() + if delivery_mode and delivery_mode not in {"closed-loop", "plan-only"}: + raise AgentWorkAppError("delivery_mode must be closed-loop or plan-only") + + +def dev_pipeline_validate_pipeline_run_inputs(template: dict[str, Any], submitted_inputs: list[Any]) -> list[dict[str, Any]]: + contract_inputs = dev_pipeline_template_required_inputs(template) + expected_ids = [str(item.get("id") or "") for item in contract_inputs] + actual_ids = [str(item.get("id") or "") if isinstance(item, dict) else "" for item in submitted_inputs] + if actual_ids != expected_ids: + missing = [item for item in expected_ids if item not in actual_ids] + extra = [item for item in actual_ids if item not in expected_ids] + details = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if extra: + details.append(f"extra: {', '.join(extra)}") + if not details: + details.append("input order does not match the selected template") + raise AgentWorkAppError(f"inputs must match template input IDs exactly ({'; '.join(details)})") + + normalized: list[dict[str, Any]] = [] + for contract, submitted in zip(contract_inputs, submitted_inputs): + if not isinstance(submitted, dict): + raise AgentWorkAppError(f"input {contract.get('id')} must be an object") + input_id = str(contract.get("id") or "") + kind = dev_pipeline_input_type(submitted.get("kind"), str(contract.get("kind") or "")) + contract_kind = str(contract.get("kind") or "") + if kind not in PIPELINE_RUN_INPUT_TYPES: + raise AgentWorkAppError(f"input {input_id} has unsupported kind: {kind}") + if kind != contract_kind: + raise AgentWorkAppError(f"input {input_id} kind must be {contract_kind}") + source = dev_pipeline_input_source(submitted.get("source"), str(contract.get("source") or "user")) + contract_source = str(contract.get("source") or "user") + if source != contract_source: + raise AgentWorkAppError(f"input {input_id} source must be {contract_source}") + extras = sorted(set(submitted) - dev_pipeline_run_input_allowed_fields(kind, source)) + if extras: + raise AgentWorkAppError(f"input {input_id} has invalid field(s) for {kind}/{source}: {', '.join(extras)}") + if source == "user" and bool(contract.get("required", True)) and not dev_pipeline_run_input_has_user_value(kind, submitted): + raise AgentWorkAppError(f"required user input is missing: {input_id}") + merged = deepcopy(contract) + merged["kind"] = kind + merged["input_type"] = kind + merged["source"] = source + if source == "user": + answer, answer_values, answer_notes = dev_pipeline_run_input_answer(kind, submitted) + merged["answer"] = answer + merged["answer_values"] = answer_values + merged["answer_notes"] = answer_notes + if kind == "path": + merged["paths"] = answer_values + elif kind == "image": + merged["image_refs"] = answer_values + merged["image_notes"] = answer + elif kind == "evidence": + merged["artifacts"] = answer_values + merged["evidence_policy"] = answer + merged["status"] = "provided" if dev_pipeline_run_input_has_user_value(kind, submitted) else str(contract.get("status") or "missing") + merged["provided_at"] = datetime.now(timezone.utc).isoformat() if dev_pipeline_run_input_has_user_value(kind, submitted) else "" + elif kind == "image": + image_refs = dev_pipeline_text_list(submitted.get("image_refs"), []) + image_notes = dev_pipeline_text(submitted.get("image_notes"), "") + answer_notes = dev_pipeline_text(submitted.get("answer_notes"), "") + if image_refs: + merged["image_refs"] = list(dict.fromkeys([*image_refs, *dev_pipeline_text_list(merged.get("image_refs"), [])])) + merged["image_notes"] = image_notes or str(merged.get("image_notes") or "") + merged["answer_notes"] = answer_notes + merged["status"] = "provided" + merged["provided_at"] = datetime.now(timezone.utc).isoformat() + normalized.append(merged) + return dev_pipeline_required_inputs(normalized) + + +def dev_pipeline_prompt_from_run_inputs(inputs: list[dict[str, Any]]) -> str: + for item in inputs: + if str(item.get("source") or "") != "user": + continue + if str(item.get("id") or "") == "operator-thoughts" and str(item.get("answer") or "").strip(): + return str(item.get("answer") or "").strip() + for item in inputs: + if str(item.get("source") or "") == "user" and str(item.get("answer") or "").strip(): + return str(item.get("answer") or "").strip() + return "Manual Run Pipeline request." + + +def dev_pipeline_start_pipeline_run(payload: dict[str, Any], *, spawn: bool = True) -> dict[str, Any]: + dev_pipeline_validate_pipeline_run_payload(payload) + root = DEV_PIPELINE_STUDIO_ROOT + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + raise AgentWorkAppError(f"Dev Pipeline Studio manifest not found: {dev_pipeline_relative(manifest_path)}") + if dev_pipeline_ensure_builtin_pipelines(manifest): + write_json_path(manifest_path, manifest) + + project_id = str(payload.get("project_id") or "") + template_id = str(payload.get("template_id") or "") + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + project = dev_pipeline_find(projects, project_id, project_id) + template = dev_pipeline_find(templates, template_id, template_id) + if not project or str(project.get("id") or "") != project_id: + raise AgentWorkAppError(f"Unknown pipeline project: {project_id}") + if not template or str(template.get("id") or "") != template_id: + raise AgentWorkAppError(f"Unknown pipeline template: {template_id}") + + dev_pipeline_apply_generic_blueprint(template) + run_inputs = dev_pipeline_validate_pipeline_run_inputs(template, payload.get("inputs") if isinstance(payload.get("inputs"), list) else []) + prompt = dev_pipeline_prompt_from_run_inputs(run_inputs) + template["required_inputs"] = run_inputs + execution_run = dev_pipeline_seed_execution_e2e( + root, + manifest, + project, + template, + { + "triggered_by": "pipeline-run-api", + "issue_id": "", + "issue_subject": "Run Pipeline", + "prompt": prompt, + "message": "Run Pipeline request accepted through input contract", + "run_inputs": run_inputs, + "delivery_mode": str(payload.get("delivery_mode") or ""), + }, + ) + + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + defaults["project_id"] = project_id + defaults["template_id"] = template_id + manifest["defaults"] = defaults + manifest["active_run_id"] = str(execution_run.get("run_id") or "") + manifest["status"] = str(execution_run.get("status") or "running") + if template_id == MULTIPIPELINE_TEMPLATE_ID: + manifest["status_detail"] = "Run Pipeline accepted the multipipeline ProReq chain and started four sequential request-artifact passes" + elif template_id == PROREQ_LIGHT_TEMPLATE_ID: + mode = str(execution_run.get("delivery_mode") or "closed-loop") + manifest["status_detail"] = f"Run Pipeline accepted ProReq-light in {mode} mode using Codex Exec instead of live Pro API dispatch" + elif template_id == PATCH_SWARM_TEMPLATE_ID: + manifest["status_detail"] = "Run Pipeline accepted Patch Swarm with ten ProReq patch lanes, 100+ fixture candidates, provider adapters, and one dedicated integrator" + else: + manifest["status_detail"] = "Run Pipeline request accepted through the selected template input contract" + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + "pipeline_run_requested", + project_id, + template_id, + { + "execution_run_id": str(execution_run.get("run_id") or ""), + "input_ids": [str(item.get("id") or "") for item in run_inputs], + "delivery_mode": str(execution_run.get("delivery_mode") or ""), + }, + ) + if spawn and execution_run.get("run_id") and str(execution_run.get("status") or "") == "running": + dev_pipeline_spawn_execution_e2e(root, project_id, template_id, str(execution_run.get("run_id") or "")) + route = { + "project_id": project_id, + "template_id": template_id, + "run_id": str(execution_run.get("run_id") or ""), + "status": str(execution_run.get("status") or "running"), + "default": template_id == DEFAULT_DEV_PIPELINE_TEMPLATE_ID, + "url": "/dev-pipeline-studio#pipeline-flow", + } + return { + "schema_version": "cento.pipeline_run_response.v1", + **route, + "pipeline_route": route, + "execution_run": execution_run, + } + + +def dev_pipeline_latest_workset_dir(workset_id: str, since: datetime) -> Path | None: + slug = dev_pipeline_workset_slug(workset_id) + candidates = sorted((ROOT_DIR / ".cento" / "worksets").glob(f"{slug}_*"), key=lambda path: path.stat().st_mtime if path.exists() else 0, reverse=True) + for path in candidates: + try: + modified = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) + except OSError: + continue + if modified >= since - timedelta(seconds=2): + return path + return None + + +def dev_pipeline_workset_event_logs(workset_events_rel: str) -> list[dict[str, Any]]: + if not workset_events_rel: + return [] + rows = read_event_rows(ROOT_DIR / workset_events_rel, limit=80) + logs: list[dict[str, Any]] = [] + for row in rows: + timestamp = parse_iso_datetime(row.get("ts") or row.get("timestamp")) or datetime.now(timezone.utc) + event = str(row.get("event") or "workset_event").replace("_", " ") + task_id = str(row.get("task_id") or row.get("workset_id") or "workset") + logs.append( + { + "timestamp": timestamp.isoformat(), + "stage": "execution", + "source": task_id, + "message": event, + } + ) + return logs + + +def dev_pipeline_delivery_steps_from_receipt(receipt: dict[str, Any], started: datetime, finished: datetime) -> list[dict[str, Any]]: + tasks = receipt.get("tasks") if isinstance(receipt.get("tasks"), dict) else {} + if len(tasks) > 1 or int(receipt.get("max_parallel") or 1) > 1: + elapsed = max(1, int(round((finished - started).total_seconds()))) + task_rows: list[dict[str, Any]] = [] + for index, (task_id, task) in enumerate(tasks.items(), start=1): + if not isinstance(task, dict): + continue + task_status = dev_pipeline_execution_status_label(task.get("status") or receipt.get("status")) + worker_title = task.get("worker_id") or task_id + task_rows.append( + { + "id": f"parallel-worker-{task_id}", + "title": f"Worker: {worker_title}", + "status": task_status, + "duration": duration_label(elapsed), + "duration_seconds": elapsed, + "started_at": started.isoformat(), + "finished_at": finished.isoformat(), + "file": ", ".join(str(path) for path in task.get("write_paths", []) if isinstance(path, str)) or str(task.get("api_worker_receipt") or ""), + } + ) + patch_status = "completed" if receipt.get("patch_bundles") else ("blocked" if receipt.get("failed_tasks") else "queued") + integration_status = "completed" if receipt.get("integration_receipts") else ("blocked" if receipt.get("failed_tasks") else "queued") + validation_status = "completed" if receipt.get("validation_receipts") else ("blocked" if receipt.get("failed_tasks") else "queued") + apply_status = "completed" if receipt.get("status") == "completed" and (receipt.get("changed_paths") or str(receipt.get("apply") or "") == "none") else ("blocked" if receipt.get("failed_tasks") else "queued") + return [ + {"id": "resolve-parallel-inputs", "title": "Resolve contract and exclusive write paths", "status": "completed", "duration": "0s", "duration_seconds": 0, "started_at": started.isoformat(), "finished_at": started.isoformat(), "file": "execution/execution_run.json"}, + {"id": "write-parallel-workset", "title": "Write parallel workset manifest", "status": "completed", "duration": "0s", "duration_seconds": 0, "started_at": started.isoformat(), "finished_at": started.isoformat(), "file": str(receipt.get("source") or "")}, + *task_rows, + {"id": "collect-worker-artifacts", "title": "Collect worker artifacts", "status": patch_status, "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": ", ".join(str(value) for value in receipt.get("patch_bundles", []) if isinstance(value, str))}, + {"id": "integrate-sequentially", "title": "Integrate patches sequentially", "status": integration_status, "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": ", ".join(str(value) for value in receipt.get("integration_receipts", []) if isinstance(value, str))}, + {"id": "run-parallel-validation", "title": "Run parallel validation gates", "status": validation_status, "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": ", ".join(str(value) for value in receipt.get("validation_receipts", []) if isinstance(value, str))}, + {"id": "apply-worktree", "title": "Apply or confirm dry-run handoff", "status": apply_status, "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": ", ".join(str(value) for value in receipt.get("changed_paths", []) if isinstance(value, str)) or str(receipt.get("apply") or "")}, + {"id": "collect-parallel-evidence", "title": "Collect receipts, cost, and evidence", "status": "completed" if receipt else "blocked", "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": str(receipt.get("events") or "")}, + ] + task = next(iter(tasks.values()), {}) if tasks else {} + task_status = dev_pipeline_execution_status_label(task.get("status") or receipt.get("status")) + elapsed = max(0, int(round((finished - started).total_seconds()))) + api_status = "completed" if task.get("api_worker_receipt") and task_status != "failed" else task_status + patch_status = "completed" if task.get("patch_bundle") else ("blocked" if task_status in {"blocked", "failed", "rejected"} else "queued") + integration_status = "completed" if task.get("integration_receipt") else ("blocked" if task_status in {"blocked", "failed", "rejected"} else "queued") + apply_status = "completed" if task_status == "completed" and (task.get("apply_receipt") or str(receipt.get("apply") or "") == "none") else ("blocked" if task_status in {"blocked", "failed", "rejected"} else "queued") + return [ + {"id": "resolve-prompt", "title": "Resolve prompt and target paths", "status": "completed", "duration": "0s", "duration_seconds": 0, "started_at": started.isoformat(), "finished_at": started.isoformat(), "file": "execution/execution_run.json"}, + {"id": "write-workset", "title": "Write executable workset", "status": "completed", "duration": "0s", "duration_seconds": 0, "started_at": started.isoformat(), "finished_at": started.isoformat(), "file": str(receipt.get("source") or "")}, + {"id": "api-worker", "title": "OpenAI patch proposal worker", "status": api_status, "duration": duration_label(max(1, elapsed)), "duration_seconds": max(1, elapsed), "started_at": started.isoformat(), "finished_at": finished.isoformat(), "file": str(task.get("api_worker_receipt") or "")}, + {"id": "materialize-patch", "title": "Materialize patch bundle", "status": patch_status, "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": str(task.get("patch_bundle") or "")}, + {"id": "integrate-sequential", "title": "Integrate patch sequentially", "status": integration_status, "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": str(task.get("integration_receipt") or "")}, + {"id": "apply-worktree", "title": "Apply or confirm dry-run handoff", "status": apply_status, "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": str(task.get("apply_receipt") or receipt.get("apply") or "")}, + {"id": "collect-receipts", "title": "Collect receipts, cost, and evidence", "status": "completed" if receipt else "blocked", "duration": "0s", "duration_seconds": 0, "started_at": finished.isoformat(), "finished_at": finished.isoformat(), "file": str(receipt.get("events") or "")}, + ] + + +def dev_pipeline_delivery_artifacts(run_payload: dict[str, Any], receipt: dict[str, Any]) -> list[dict[str, Any]]: + rels: list[str] = [] + for key in ("workset_manifest", "workset_receipt", "workset_events", "stdout_log", "stderr_log"): + if run_payload.get(key): + rels.append(str(run_payload[key])) + for key in ("workers", "artifacts", "patch_bundles", "integration_receipts", "validation_receipts"): + values = receipt.get(key) if isinstance(receipt.get(key), list) else [] + rels.extend(str(value) for value in values if value) + rels.extend(str(record.get(key) or "") for record in (receipt.get("tasks") or {}).values() for key in ("apply_receipt", "taskstream_evidence") if isinstance(record, dict) and record.get(key)) + artifacts: list[dict[str, Any]] = [] + seen: set[str] = set() + for rel in rels: + clean = rel.strip() + if not clean or clean in seen: + continue + seen.add(clean) + path = ROOT_DIR / clean + artifacts.append({"name": Path(clean).name, "path": clean, "size": file_size_label(path), "exists": path.exists()}) + return artifacts + + +def dev_pipeline_execution_parallel_summary(run_payload: dict[str, Any], receipt: dict[str, Any]) -> dict[str, Any]: + workset_rel = str(run_payload.get("workset_manifest") or receipt.get("source") or "") + workset = read_json_path(ROOT_DIR / workset_rel) if workset_rel else {} + raw_tasks = workset.get("tasks") if isinstance(workset.get("tasks"), list) else [] + receipt_tasks = receipt.get("tasks") if isinstance(receipt.get("tasks"), dict) else {} + max_parallel = int(receipt.get("max_parallel") or run_payload.get("workset_max_parallel") or workset.get("max_parallel") or 1) + task_count = len(raw_tasks) or int(receipt.get("total_tasks") or run_payload.get("workset_task_count") or 0) + enabled = max_parallel > 1 or task_count > 1 or str(run_payload.get("execution_model") or workset.get("execution_model") or "") == "parallel" + if not enabled: + return {"enabled": False} + tasks: list[dict[str, Any]] = [] + if raw_tasks: + for index, task in enumerate(raw_tasks, start=1): + if not isinstance(task, dict): + continue + task_id = str(task.get("id") or f"task-{index}") + receipt_task = receipt_tasks.get(task_id) if isinstance(receipt_tasks.get(task_id), dict) else {} + tasks.append( + { + "id": task_id, + "title": str(task.get("task") or task_id), + "worker_id": str(task.get("worker_id") or receipt_task.get("worker_id") or task_id), + "status": dev_pipeline_execution_status_label(receipt_task.get("status") or ("queued" if run_payload.get("status") == "running" else run_payload.get("status") or "queued")), + "write_paths": [str(value) for value in task.get("write_paths", []) if isinstance(value, str)], + "depends_on": [str(value) for value in task.get("depends_on", []) if isinstance(value, str)], + "patch_bundle": str(receipt_task.get("patch_bundle") or ""), + "integration_receipt": str(receipt_task.get("integration_receipt") or ""), + "validation_receipt": str(receipt_task.get("validation_receipt") or ""), + } + ) + elif receipt_tasks: + for task_id, receipt_task in receipt_tasks.items(): + if not isinstance(receipt_task, dict): + continue + tasks.append( + { + "id": str(task_id), + "title": str(receipt_task.get("worker_id") or task_id), + "worker_id": str(receipt_task.get("worker_id") or task_id), + "status": dev_pipeline_execution_status_label(receipt_task.get("status") or receipt.get("status") or "queued"), + "write_paths": [str(value) for value in receipt_task.get("write_paths", []) if isinstance(value, str)], + "depends_on": [str(value) for value in receipt_task.get("depends_on", []) if isinstance(value, str)], + "patch_bundle": str(receipt_task.get("patch_bundle") or ""), + "integration_receipt": str(receipt_task.get("integration_receipt") or ""), + "validation_receipt": str(receipt_task.get("validation_receipt") or ""), + } + ) + return { + "enabled": True, + "workset_manifest": workset_rel, + "workset_receipt": str(run_payload.get("workset_receipt") or ""), + "max_parallel": max_parallel, + "task_count": task_count or len(tasks), + "integration": str(receipt.get("integration") or workset.get("integration") or "sequential"), + "integration_model_policy": workset.get("integration_model_policy") if isinstance(workset.get("integration_model_policy"), dict) else { + "mode": "deterministic-first", + "fallback": "only-if-needed", + "model_ceiling": DEV_PIPELINE_INTEGRATION_MODEL_CEILING, + "profile": "api-mini-integrator", + }, + "apply": str(receipt.get("apply") or run_payload.get("apply_mode") or "sequential"), + "no_shared_files": bool(receipt.get("no_shared_files", True)), + "summary": f"{task_count or len(tasks)} worker lanes, max {max_parallel} concurrent, one serialized integration lane", + "tasks": tasks, + } + + +def dev_pipeline_proreq_light_delivery_command() -> tuple[list[str], int]: + runtime_profile = os.environ.get("CENTO_PROREQ_LIGHT_RUNTIME_PROFILE", "codex-fast") + max_parallel = os.environ.get("CENTO_PROREQ_LIGHT_MAX_PARALLEL", "3") + worker_timeout = os.environ.get("CENTO_PROREQ_LIGHT_WORKER_TIMEOUT", "180") + delivery_timeout = int(os.environ.get("CENTO_PROREQ_LIGHT_DELIVERY_TIMEOUT", "1800")) + command = [ + sys.executable, + str(ROOT_DIR / "scripts" / "proreq_light.py"), + "deliver", + "--max-parallel", + max_parallel, + "--runtime-profile", + runtime_profile, + "--worker-timeout", + worker_timeout, + "--delivery-timeout", + str(delivery_timeout), + "--validation", + "smoke", + "--json", + ] + if os.environ.get("CENTO_PROREQ_LIGHT_NO_FULL_CHECK", "").lower() in {"1", "true", "yes", "on"}: + command.append("--no-full-check") + return command, delivery_timeout + + +def dev_pipeline_run_proreq_light_closed_loop_delivery( + root: Path, + execution_manifest_rel: str, + execution_manifest: dict[str, Any], + run_payload: dict[str, Any], + steps: list[dict[str, Any]], + logs: list[dict[str, Any]], + started: datetime, + run_id: str, +) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]], bool]: + command, delivery_timeout = dev_pipeline_proreq_light_delivery_command() + delivery_dir = dev_pipeline_root_path(root, f"execution/delivery/{run_id}") + delivery_dir.mkdir(parents=True, exist_ok=True) + stdout_path = delivery_dir / "closed-loop.stdout.log" + stderr_path = delivery_dir / "closed-loop.stderr.log" + stdout_rel = dev_pipeline_relative(stdout_path) + stderr_rel = dev_pipeline_relative(stderr_path) + delivery_started = datetime.now(timezone.utc) + delivery_step = { + "id": "closed-loop-delivery", + "title": "Launch Codex workers, integrate patches, validate evidence", + "status": "running", + "command": shlex.join(command), + "exit_code": None, + "duration": "0s", + "duration_seconds": 0, + "started_at": delivery_started.isoformat(), + "finished_at": "", + "stdout_tail": "", + "stderr_tail": "", + } + steps = [*steps, delivery_step] + logs = [ + *logs, + { + "timestamp": delivery_started.isoformat(), + "stage": "execution", + "source": "closed-loop-delivery", + "message": f"Dispatching {shlex.join(command)}", + }, + ][-120:] + run_payload["steps"] = steps + run_payload["logs"] = logs + run_payload["stdout_log"] = stdout_rel + run_payload["stderr_log"] = stderr_rel + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + stdout = "" + stderr = "" + returncode: int | None = None + timed_out = False + try: + proc = subprocess.run( + command, + cwd=ROOT_DIR, + text=True, + capture_output=True, + timeout=delivery_timeout + 30, + check=False, + ) + stdout = proc.stdout or "" + stderr = proc.stderr or "" + returncode = proc.returncode + except subprocess.TimeoutExpired as exc: + timed_out = True + stdout = exc.stdout if isinstance(exc.stdout, str) else "" + stderr = exc.stderr if isinstance(exc.stderr, str) else "" + stderr = (stderr + f"\nTimed out after {delivery_timeout + 30}s.\n").strip() + + stdout_path.write_text(stdout, encoding="utf-8") + stderr_path.write_text(stderr, encoding="utf-8") + delivery_finished = datetime.now(timezone.utc) + duration_seconds = max(1, int(round((delivery_finished - delivery_started).total_seconds()))) + try: + delivery_payload = json.loads(stdout or "{}") + except json.JSONDecodeError: + delivery_payload = {} + if not isinstance(delivery_payload, dict) or not delivery_payload: + delivery_payload = read_json_path( + ROOT_DIR / f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_delivery.json" + ) + delivery_status = str(delivery_payload.get("status") or ("timeout" if timed_out else "blocked")) + step_status = "completed" if delivery_status == "completed" and returncode == 0 else "blocked" + receipt_rel = str(delivery_payload.get("workset_receipt") or "") + receipt = read_json_path(ROOT_DIR / receipt_rel) if receipt_rel else {} + workset_result = delivery_payload.get("workset_result") if isinstance(delivery_payload.get("workset_result"), dict) else {} + if receipt: + run_payload["workset_receipt"] = receipt_rel + run_payload["workset_dir"] = str(workset_result.get("workset_dir") or receipt.get("workset_dir") or "") + run_payload["workset_events"] = str(receipt.get("events") or "") + run_payload["total_ai_cost_usd"] = float(receipt.get("total_cost_usd") or 0.0) + run_payload["changed_paths"] = [str(value) for value in receipt.get("changed_paths", []) if isinstance(value, str)] + else: + run_payload["total_ai_cost_usd"] = 0.0 + run_payload["changed_paths"] = [] + run_payload["closed_loop_delivery"] = str( + delivery_payload.get("delivery") + or f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_delivery.json" + ) + run_payload["closed_loop_evidence"] = f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/{run_id}/closed_loop_evidence.json" + run_payload["closed_loop_incident"] = str(delivery_payload.get("incident") or "") + run_payload["artifacts"] = dev_pipeline_hard_proreq_artifacts(run_id) + run_payload["facts"] = [ + {"label": "Engine", "value": "cento proreq-light deliver"}, + {"label": "Runtime", "value": f"local-command / {delivery_payload.get('runtime_profile') or os.environ.get('CENTO_PROREQ_LIGHT_RUNTIME_PROFILE', 'codex-fast')}"}, + {"label": "Apply", "value": str(delivery_payload.get("apply") or "clean-owned-paths")}, + {"label": "Parallel workers", "value": str(receipt.get("total_tasks") or len((receipt.get("tasks") or {}) if isinstance(receipt.get("tasks"), dict) else {}))}, + {"label": "Max parallel", "value": str(delivery_payload.get("max_parallel") or os.environ.get("CENTO_PROREQ_LIGHT_MAX_PARALLEL", "3"))}, + {"label": "AI cost", "value": f"${float(run_payload.get('total_ai_cost_usd') or 0.0):.6f} local ledger; dashboard remains source of truth"}, + {"label": "Changed paths", "value": ", ".join(run_payload["changed_paths"]) if run_payload["changed_paths"] else "none"}, + ] + steps[-1] = { + **delivery_step, + "status": step_status, + "exit_code": returncode, + "duration": duration_label(duration_seconds), + "duration_seconds": duration_seconds, + "finished_at": delivery_finished.isoformat(), + "stdout_tail": stdout[-1200:], + "stderr_tail": stderr[-1200:], + } + logs = [ + *logs, + *dev_pipeline_workset_event_logs(str(run_payload.get("workset_events") or "")), + { + "timestamp": delivery_finished.isoformat(), + "stage": "handoff", + "source": "closed-loop-delivery", + "message": f"ProReq-light closed-loop delivery finished with status {delivery_status}", + "exit_code": returncode, + }, + ][-120:] + run_payload["steps"] = steps + run_payload["logs"] = logs + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + return run_payload, steps, logs, step_status != "completed" + + +def dev_pipeline_finish_hard_proreq_execution(root: Path, project_id: str, template_id: str, run_id: str) -> None: + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + return + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + project = dev_pipeline_find(projects, project_id, project_id) + template = dev_pipeline_find(templates, template_id, template_id) + if not project or not template: + return + dev_pipeline_apply_generic_blueprint(template) + execution_manifest_rel = str(template.get("execution_manifest") or "execution/execution_manifest.json") + execution_manifest = {} + run_payload = dev_pipeline_artifact_json(root, f"execution/runs/{run_id}.json") or dev_pipeline_artifact_json(root, "execution/execution_run.json") + if str(run_payload.get("run_id") or "") != run_id or str(run_payload.get("status") or "") != "running": + return + is_light = str(template_id or template.get("id") or "") == PROREQ_LIGHT_TEMPLATE_ID or str(run_payload.get("source") or "").startswith("cento-proreq-light") + started = parse_iso_datetime(run_payload.get("started_at")) or datetime.now(timezone.utc) + steps = [dict(step) for step in run_payload.get("steps", []) if isinstance(step, dict)] + logs = [item for item in run_payload.get("logs", []) if isinstance(item, dict)] + run_failed = False + run_blocked = False + for index, step in enumerate(steps): + step_id = str(step.get("id") or f"step-{index + 1}") + command = dev_pipeline_execution_command_for_step(step_id) + if is_light and step_id == "write-ui-screenshot-request": + command = ["python3", "scripts/dev_pipeline_hard_proreq.py", "light-screenshot"] + step_started = datetime.now(timezone.utc) + steps[index] = { + **step, + "status": "running", + "started_at": step_started.isoformat(), + "finished_at": "", + "command": shlex.join(command), + } + run_payload["steps"] = steps + run_payload["stages"] = dev_pipeline_hard_proreq_stage_payloads(started, "running") + logs.append({"timestamp": step_started.isoformat(), "stage": "execution", "source": step_id, "message": f"{step.get('title') or step_id} started", "command": shlex.join(command)}) + run_payload["logs"] = logs[-100:] + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + step_timeout = int(os.environ.get("CENTO_HARD_PROREQ_STEP_TIMEOUT", "90")) + if step_id == "write-ui-screenshot-request": + step_timeout = int(os.environ.get("CENTO_HARD_PROREQ_STEP_TIMEOUT", "90")) if is_light else int(os.environ.get("CENTO_HARD_PROREQ_IMAGE_TIMEOUT", "240")) + 30 + if step_id == "dispatch-codex-pro-backend-plan": + step_timeout = int(os.environ.get("CENTO_PROREQ_LIGHT_CODEX_TIMEOUT", "900")) + 30 + result = subprocess.run(command, cwd=ROOT_DIR, text=True, capture_output=True, timeout=step_timeout) + elapsed = (datetime.now(timezone.utc) - step_started).total_seconds() + if elapsed < DEV_PIPELINE_EXECUTION_MIN_STEP_SECONDS: + time.sleep(DEV_PIPELINE_EXECUTION_MIN_STEP_SECONDS - elapsed) + step_finished = datetime.now(timezone.utc) + duration_seconds = max(1, int(round((step_finished - step_started).total_seconds()))) + muted_step = bool(step.get("muted")) or str(step.get("lane") or "") == "frontend" + status = "muted" if muted_step and result.returncode == 0 else ("completed" if result.returncode == 0 else "failed") + if result.returncode != 0: + run_failed = True + steps[index] = { + **step, + "id": step_id, + "title": str(step.get("title") or step_id), + "status": status, + "command": shlex.join(command), + "exit_code": result.returncode, + "duration": duration_label(duration_seconds), + "duration_seconds": duration_seconds, + "started_at": step_started.isoformat(), + "finished_at": step_finished.isoformat(), + "stdout_tail": result.stdout[-1200:], + "stderr_tail": result.stderr[-1200:], + } + logs.append({"timestamp": step_finished.isoformat(), "stage": "execution", "source": step_id, "message": f"{step.get('title') or step_id} finished with status {status}", "exit_code": result.returncode}) + run_payload["steps"] = steps + run_payload["logs"] = logs[-120:] + run_payload["artifacts"] = dev_pipeline_hard_proreq_artifacts(run_id) + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + if run_failed: + break + + if is_light and not run_failed and str(run_payload.get("delivery_mode") or "closed-loop") == "closed-loop": + run_payload, steps, logs, delivery_blocked = dev_pipeline_run_proreq_light_closed_loop_delivery( + root, + execution_manifest_rel, + execution_manifest, + run_payload, + steps, + logs, + started, + run_id, + ) + run_blocked = bool(delivery_blocked) + + finished = datetime.now(timezone.utc) + run_status = "failed" if run_failed else ("blocked" if run_blocked else "completed") + if run_failed: + for index in range(index + 1, len(steps)): + steps[index] = {**steps[index], "status": "blocked", "stderr_tail": "Skipped because an upstream hard proreq step failed."} + run_payload["status"] = run_status + run_payload["finished_at"] = finished.isoformat() + run_payload["duration_seconds"] = max(0, int(round((finished - started).total_seconds()))) + run_payload["stages"] = dev_pipeline_hard_proreq_stage_payloads(started, run_status, finished) + run_payload["steps"] = steps + run_payload["logs"] = [ + *logs, + { + "timestamp": finished.isoformat(), + "stage": "handoff", + "source": "proreq-light" if is_light else "hard-proreq", + "message": ("ProReq-light closed-loop run finished" if is_light and str(run_payload.get("delivery_mode") or "") == "closed-loop" else ("ProReq-light Codex Exec planning finished" if is_light else "Hard proreq planning finished")) + f" with status {run_status}", + }, + ][-120:] + run_payload["artifacts"] = dev_pipeline_hard_proreq_artifacts(run_id) + run_payload["written_at"] = datetime.now(timezone.utc).isoformat() + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + manifest["active_run_id"] = run_id + manifest["status"] = run_status + manifest["status_detail"] = ( + ( + "ProReq-light closed-loop pipeline completed; Codex Exec simulated the Pro planning lane, launched local Codex workers, integrated accepted patches, and wrote validation evidence" + if run_status == "completed" + else "ProReq-light closed-loop pipeline blocked; incident and closed-loop evidence artifacts were written for follow-up" + ) + if is_light + else "Hard proreq pipeline completed; GPT pro backend request, schema, Cento context, muted frontend request, backend work, integration, and validation artifacts are ready" + ) + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + "pipeline_proreq_light_finished" if is_light else "pipeline_hard_proreq_finished", + str(project.get("id") or project_id), + str(template.get("id") or template_id), + {"execution_run_id": run_id, "status": run_status, "artifacts": [item["path"] for item in dev_pipeline_hard_proreq_artifacts(run_id)]}, + ) + + +def dev_pipeline_finish_multipipeline_execution(root: Path, project_id: str, template_id: str, run_id: str) -> None: + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + return + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + project = dev_pipeline_find(projects, project_id, project_id) + template = dev_pipeline_find(templates, template_id, template_id) + if not project or not template: + return + dev_pipeline_apply_generic_blueprint(template) + execution_manifest_rel = str(template.get("execution_manifest") or "execution/multipipeline_execution_manifest.json") + execution_manifest = {} + run_payload = dev_pipeline_artifact_json(root, f"execution/runs/{run_id}.json") or dev_pipeline_artifact_json(root, "execution/execution_run.json") + if str(run_payload.get("run_id") or "") != run_id or str(run_payload.get("status") or "") != "running": + return + started = parse_iso_datetime(run_payload.get("started_at")) or datetime.now(timezone.utc) + steps = [dict(step) for step in run_payload.get("steps", []) if isinstance(step, dict)] + logs = [item for item in run_payload.get("logs", []) if isinstance(item, dict)] + run_failed = False + env = os.environ.copy() + env["CENTO_DEV_PIPELINE_STUDIO_ROOT"] = str(root) + for index, step in enumerate(steps): + step_id = str(step.get("id") or f"step-{index + 1}") + command = dev_pipeline_execution_command_for_step(step_id) + step_started = datetime.now(timezone.utc) + steps[index] = { + **step, + "status": "running", + "started_at": step_started.isoformat(), + "finished_at": "", + "command": shlex.join(command), + } + run_payload["steps"] = steps + run_payload["stages"] = dev_pipeline_multipipeline_stage_payloads(started, "running") + logs.append({"timestamp": step_started.isoformat(), "stage": "execution", "source": step_id, "message": f"{step.get('title') or step_id} started", "command": shlex.join(command)}) + run_payload["logs"] = logs[-100:] + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + step_timeout = int(os.environ.get("CENTO_MULTIPIPELINE_STEP_TIMEOUT", "60")) + result = subprocess.run(command, cwd=ROOT_DIR, text=True, capture_output=True, timeout=step_timeout, env=env) + elapsed = (datetime.now(timezone.utc) - step_started).total_seconds() + if elapsed < DEV_PIPELINE_EXECUTION_MIN_STEP_SECONDS: + time.sleep(DEV_PIPELINE_EXECUTION_MIN_STEP_SECONDS - elapsed) + step_finished = datetime.now(timezone.utc) + duration_seconds = max(1, int(round((step_finished - step_started).total_seconds()))) + muted_step = bool(step.get("muted")) or str(step.get("lane") or "") == "frontend" + status = "muted" if muted_step and result.returncode == 0 else ("completed" if result.returncode == 0 else "failed") + if result.returncode != 0: + run_failed = True + steps[index] = { + **step, + "id": step_id, + "title": str(step.get("title") or step_id), + "status": status, + "command": shlex.join(command), + "exit_code": result.returncode, + "duration": duration_label(duration_seconds), + "duration_seconds": duration_seconds, + "started_at": step_started.isoformat(), + "finished_at": step_finished.isoformat(), + "stdout_tail": result.stdout[-1200:], + "stderr_tail": result.stderr[-1200:], + } + logs.append({"timestamp": step_finished.isoformat(), "stage": "execution", "source": step_id, "message": f"{step.get('title') or step_id} finished with status {status}", "exit_code": result.returncode}) + run_payload["steps"] = steps + run_payload["logs"] = logs[-120:] + run_payload["artifacts"] = dev_pipeline_multipipeline_artifacts(run_id) + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + if run_failed: + break + + finished = datetime.now(timezone.utc) + run_status = "failed" if run_failed else "completed" + if run_failed: + for blocked_index in range(index + 1, len(steps)): + steps[blocked_index] = {**steps[blocked_index], "status": "blocked", "stderr_tail": "Skipped because an upstream multipipeline ProReq step failed."} + run_payload["status"] = run_status + run_payload["finished_at"] = finished.isoformat() + run_payload["duration_seconds"] = max(0, int(round((finished - started).total_seconds()))) + run_payload["stages"] = dev_pipeline_multipipeline_stage_payloads(started, run_status, finished) + run_payload["steps"] = steps + run_payload["logs"] = [ + *logs, + {"timestamp": finished.isoformat(), "stage": "handoff", "source": "multipipeline-proreq", "message": f"Multipipeline ProReq chain finished with status {run_status}"}, + ][-120:] + run_payload["artifacts"] = dev_pipeline_multipipeline_artifacts(run_id) + run_payload["written_at"] = datetime.now(timezone.utc).isoformat() + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + manifest["active_run_id"] = run_id + manifest["status"] = run_status + manifest["status_detail"] = "Multipipeline ProReq chain completed; four sequential pass requests, UI screenshot request, ChatGPT Pro request, roadmap, and evidence artifacts are ready" + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + "pipeline_multipipeline_proreq_finished", + str(project.get("id") or project_id), + str(template.get("id") or template_id), + {"execution_run_id": run_id, "status": run_status, "artifacts": [item["path"] for item in dev_pipeline_multipipeline_artifacts(run_id)]}, + ) + + +def dev_pipeline_finish_patch_swarm_execution(root: Path, project_id: str, template_id: str, run_id: str) -> None: + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + return + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + project = dev_pipeline_find(projects, project_id, project_id) + template = dev_pipeline_find(templates, template_id, template_id) + if not project or not template: + return + dev_pipeline_apply_generic_blueprint(template) + execution_manifest_rel = str(template.get("execution_manifest") or "execution/patch_swarm_execution_manifest.json") + execution_manifest = {} + run_payload = dev_pipeline_artifact_json(root, f"execution/runs/{run_id}.json") or dev_pipeline_artifact_json(root, "execution/execution_run.json") + if str(run_payload.get("run_id") or "") != run_id or str(run_payload.get("status") or "") != "running": + return + started = parse_iso_datetime(run_payload.get("started_at")) or datetime.now(timezone.utc) + steps = [dict(step) for step in run_payload.get("steps", []) if isinstance(step, dict)] + logs = [item for item in run_payload.get("logs", []) if isinstance(item, dict)] + command = [ + sys.executable, + str(ROOT_DIR / "scripts" / "parallel_delivery.py"), + "patch-swarm", + "e2e", + "--run-id", + run_id, + "--objective", + str(run_payload.get("prompt") or "Patch Swarm UI request."), + "--candidate-target", + str(int(run_payload.get("candidate_target") or 100)), + "--max-parallel-agents", + str(int(run_payload.get("max_parallel_agents") or 5)), + "--providers", + str(run_payload.get("providers") or "codex-exec,claude-code,api-openai"), + "--fixture", + "--json", + ] + run_payload["logs"] = [ + *logs, + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "stage": "execution", + "source": "patch-swarm", + "message": f"Dispatching {shlex.join(command)}", + }, + ][-120:] + for index, step in enumerate(steps): + steps[index] = {**step, "status": "running", "started_at": datetime.now(timezone.utc).isoformat(), "command": shlex.join(command)} + run_payload["steps"] = steps + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + proc = subprocess.run(command, cwd=ROOT_DIR, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=int(os.environ.get("CENTO_PATCH_SWARM_UI_TIMEOUT", "300")), check=False) + finished = datetime.now(timezone.utc) + duration_seconds = max(1, int(round((finished - started).total_seconds()))) + try: + result = json.loads(proc.stdout or "{}") + except json.JSONDecodeError: + result = {} + run_status = "completed" if proc.returncode == 0 and result.get("status") == "completed" else "blocked" + for index, step in enumerate(steps): + step_id = str(step.get("id") or "") + is_integrator = step_id == "dedicated-integrator" + steps[index] = { + **step, + "status": run_status if is_integrator else ("completed" if run_status == "completed" else "blocked"), + "exit_code": proc.returncode, + "duration": duration_label(duration_seconds), + "duration_seconds": duration_seconds, + "finished_at": finished.isoformat(), + "stdout_tail": (proc.stdout or "")[-1200:], + "stderr_tail": (proc.stderr or "")[-1200:], + } + run_payload["status"] = run_status + run_payload["finished_at"] = finished.isoformat() + run_payload["duration_seconds"] = duration_seconds + run_payload["stages"] = dev_pipeline_patch_swarm_stage_payloads(started, run_status, finished) + run_payload["steps"] = steps + run_payload["logs"] = [ + *run_payload.get("logs", []), + { + "timestamp": finished.isoformat(), + "stage": "handoff", + "source": "patch-swarm", + "message": f"Patch Swarm finished with status {run_status}", + "exit_code": proc.returncode, + }, + ][-120:] + run_payload["patch_swarm_run_dir"] = str(result.get("run_dir") or f"workspace/runs/parallel-delivery/patch-swarm/{run_id}") + run_payload["candidate_count"] = int(result.get("candidate_count") or 0) + run_payload["selected_count"] = int(result.get("selected_count") or 0) + run_payload["safe_integrator_handoff"] = str(result.get("safe_integrator_handoff") or "") + run_payload["total_ai_cost_usd"] = float(result.get("estimated_cost_usd") or 0.0) + run_payload["artifacts"] = dev_pipeline_patch_swarm_artifacts(run_id) + run_payload["facts"] = [ + {"label": "Engine", "value": "cento parallel-delivery patch-swarm e2e"}, + {"label": "Providers", "value": str(run_payload.get("providers") or "codex-exec,claude-code,api-openai")}, + {"label": "Candidates", "value": str(run_payload.get("candidate_count") or result.get("candidate_count") or 0)}, + {"label": "Selected", "value": str(run_payload.get("selected_count") or result.get("selected_count") or 0)}, + {"label": "AI cost", "value": f"${float(run_payload.get('total_ai_cost_usd') or 0.0):.6f} fixture ledger"}, + {"label": "Handoff", "value": str(run_payload.get("safe_integrator_handoff") or "pending")}, + ] + run_payload["written_at"] = datetime.now(timezone.utc).isoformat() + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + manifest["active_run_id"] = run_id + manifest["status"] = run_status + manifest["status_detail"] = f"Patch Swarm {run_status}; candidates={run_payload.get('candidate_count', 0)} selected={run_payload.get('selected_count', 0)} cost=${float(run_payload.get('total_ai_cost_usd') or 0.0):.6f}" + budget = manifest.get("budget") if isinstance(manifest.get("budget"), dict) else {} + budget["spent_usd"] = float(run_payload.get("total_ai_cost_usd") or 0.0) + budget["cap_usd"] = 20.0 + manifest["budget"] = budget + template["budget_spent_usd"] = budget["spent_usd"] + template["budget_cap_usd"] = budget["cap_usd"] + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + "pipeline_patch_swarm_finished", + str(project.get("id") or project_id), + str(template.get("id") or template_id), + {"execution_run_id": run_id, "status": run_status, "candidate_count": run_payload.get("candidate_count", 0), "selected_count": run_payload.get("selected_count", 0)}, + ) + + +def dev_pipeline_finish_execution_e2e(root: Path, project_id: str, template_id: str, run_id: str) -> None: + with DEV_PIPELINE_EXECUTION_LOCK: + if str(template_id or "") in {HARD_PROREQ_TEMPLATE_ID, PROREQ_LIGHT_TEMPLATE_ID}: + dev_pipeline_finish_hard_proreq_execution(root, project_id, template_id, run_id) + return + if str(template_id or "") == MULTIPIPELINE_TEMPLATE_ID: + dev_pipeline_finish_multipipeline_execution(root, project_id, template_id, run_id) + return + if str(template_id or "") == PATCH_SWARM_TEMPLATE_ID: + dev_pipeline_finish_patch_swarm_execution(root, project_id, template_id, run_id) + return + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + return + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + project = dev_pipeline_find(projects, project_id, project_id) + template = dev_pipeline_find(templates, template_id, template_id) + if not project or not template: + return + execution_manifest_rel, execution_manifest, _execution_steps = dev_pipeline_execution_steps(root, template) + run_payload = dev_pipeline_artifact_json(root, f"execution/runs/{run_id}.json") or dev_pipeline_artifact_json(root, "execution/execution_run.json") + if str(run_payload.get("run_id") or "") != run_id or str(run_payload.get("status") or "") != "running": + return + started = parse_iso_datetime(run_payload.get("started_at")) or datetime.now(timezone.utc) + delivery_dir_rel = f"execution/delivery/{run_id}" + delivery_dir = dev_pipeline_root_path(root, delivery_dir_rel) + delivery_dir.mkdir(parents=True, exist_ok=True) + stdout_path = delivery_dir / "workset.stdout.log" + stderr_path = delivery_dir / "workset.stderr.log" + stdout_rel = dev_pipeline_relative(stdout_path) + stderr_rel = dev_pipeline_relative(stderr_path) + workset_rel = str(run_payload.get("workset_manifest") or "") + if DEV_PIPELINE_DELIVERY_REDIRECT_GRACE_SECONDS > 0: + run_payload["logs"] = [ + *[item for item in run_payload.get("logs", []) if isinstance(item, dict)], + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "stage": "pipeline", + "source": "redirect", + "message": f"Waiting {DEV_PIPELINE_DELIVERY_REDIRECT_GRACE_SECONDS:.1f}s before worker dispatch so the browser can land on Execution Flow", + }, + ][-80:] + run_payload["stdout_log"] = stdout_rel + run_payload["stderr_log"] = stderr_rel + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + time.sleep(DEV_PIPELINE_DELIVERY_REDIRECT_GRACE_SECONDS) + runtime = str(run_payload.get("runtime") or "api-openai") + apply_mode = str(run_payload.get("apply_mode") or "apply") + validation_mode = str(run_payload.get("validation_mode") or "smoke") + command = [ + sys.executable, + str(ROOT_DIR / "scripts" / "cento_workset.py"), + "execute", + workset_rel, + "--runtime", + runtime, + "--integrate", + "sequential", + "--validation", + validation_mode, + "--worker-timeout", + str(DEV_PIPELINE_DELIVERY_TIMEOUT_SECONDS), + ] + if runtime == "api-openai": + command.extend( + [ + "--api-profile", + DEV_PIPELINE_DELIVERY_API_PROFILE, + "--budget-usd", + f"{float(run_payload.get('budget_usd') or DEV_PIPELINE_DELIVERY_BUDGET_USD):.2f}", + "--max-budget-usd", + f"{float(run_payload.get('max_budget_usd') or DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD):.2f}", + ] + ) + elif runtime == "fixture": + command.extend(["--fixture-case", "valid"]) + if apply_mode == "apply": + command.append("--apply") + else: + command.append("--allow-dirty-owned") + command.extend( + [ + "--json", + ] + ) + running_steps = [dict(step) for step in run_payload.get("steps", []) if isinstance(step, dict)] + for index, step in enumerate(running_steps): + step_id = str(step.get("id") or "") + if step_id == "api-worker" or step_id.startswith("parallel-worker-") or step_id == "dispatch-parallel-workers": + running_steps[index] = {**step, "status": "running", "started_at": datetime.now(timezone.utc).isoformat(), "command": shlex.join(command)} + run_payload["steps"] = running_steps + run_payload["logs"] = [ + *[item for item in run_payload.get("logs", []) if isinstance(item, dict)], + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "stage": "execution", + "source": "workset", + "message": f"Dispatching {shlex.join(command)}", + }, + ] + run_payload["stdout_log"] = stdout_rel + run_payload["stderr_log"] = stderr_rel + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + proc = subprocess.Popen(command, cwd=ROOT_DIR, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + workset_dir: Path | None = None + while proc.poll() is None: + if workset_dir is None: + workset_dir = dev_pipeline_latest_workset_dir(str(run_payload.get("workset_id") or ""), started) + if workset_dir is not None: + run_payload["workset_dir"] = dev_pipeline_relative(workset_dir) + run_payload["workset_events"] = dev_pipeline_relative(workset_dir / "events.ndjson") + run_payload["logs"] = [ + *[item for item in run_payload.get("logs", []) if isinstance(item, dict)], + *dev_pipeline_workset_event_logs(str(run_payload.get("workset_events") or "")), + ][-80:] + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + time.sleep(0.8) + stdout, stderr = proc.communicate() + stdout_path.write_text(stdout or "", encoding="utf-8") + stderr_path.write_text(stderr or "", encoding="utf-8") + + result: dict[str, Any] = {} + try: + result = json.loads(stdout or "{}") + except json.JSONDecodeError: + result = {} + receipt_rel = str(result.get("workset_receipt") or "") + receipt = read_json_path(ROOT_DIR / receipt_rel) if receipt_rel else {} + finished = datetime.now(timezone.utc) + if receipt: + status = dev_pipeline_execution_status_label(receipt.get("status")) + run_payload["workset_receipt"] = receipt_rel + run_payload["workset_dir"] = str(result.get("workset_dir") or run_payload.get("workset_dir") or "") + run_payload["workset_events"] = str(receipt.get("events") or run_payload.get("workset_events") or "") + run_payload["total_ai_cost_usd"] = float(receipt.get("total_cost_usd") or 0.0) + run_payload["changed_paths"] = [str(value) for value in receipt.get("changed_paths", []) if isinstance(value, str)] + run_payload["steps"] = dev_pipeline_delivery_steps_from_receipt(receipt, started, finished) + run_payload["artifacts"] = dev_pipeline_delivery_artifacts(run_payload, receipt) + apply_label = "dry-run integrator" if str(receipt.get("apply") or "") == "none" else ("sequential integrator" if int(receipt.get("total_tasks") or len(receipt.get("tasks") or {}) or 1) > 1 else "direct worktree") + run_payload["facts"] = [ + {"label": "Engine", "value": "cento workset execute"}, + {"label": "Runtime", "value": str(receipt.get("runtime") or "api-openai")}, + {"label": "Apply", "value": apply_label}, + {"label": "Integration model ceiling", "value": f"{DEV_PIPELINE_INTEGRATION_MODEL_CEILING} only if needed"}, + {"label": "Parallel workers", "value": str(receipt.get("total_tasks") or len(receipt.get("tasks") or {}))}, + {"label": "Max parallel", "value": str(receipt.get("max_parallel") or run_payload.get("workset_max_parallel") or 1)}, + {"label": "AI cost", "value": f"${float(receipt.get('total_cost_usd') or 0.0):.6f}"}, + {"label": "Budget", "value": f"${float(receipt.get('target_budget_usd') or 0.0):.2f} target / ${float(receipt.get('max_budget_usd') or 0.0):.2f} cap"}, + {"label": "Changed paths", "value": ", ".join(run_payload["changed_paths"]) if run_payload["changed_paths"] else "none"}, + ] + else: + status = "failed" + run_payload["steps"] = [ + {**step, "status": "failed" if str(step.get("id") or "") == "api-worker" else dev_pipeline_execution_status_label(step.get("status"))} + for step in run_payload.get("steps", []) + if isinstance(step, dict) + ] + run_payload["artifacts"] = dev_pipeline_delivery_artifacts(run_payload, {}) + run_status = "completed" if status == "completed" and proc.returncode == 0 else ("blocked" if status in {"blocked", "rejected"} else status) + run_payload["status"] = run_status + run_payload["finished_at"] = finished.isoformat() + run_payload["duration_seconds"] = max(0, int(round((finished - started).total_seconds()))) + run_payload["stages"] = dev_pipeline_delivery_stage_payloads(started, run_status, finished, bool(run_payload.get("target_paths"))) + run_payload["logs"] = [ + *[item for item in run_payload.get("logs", []) if isinstance(item, dict)], + *dev_pipeline_workset_event_logs(str(run_payload.get("workset_events") or "")), + { + "timestamp": finished.isoformat(), + "stage": "handoff", + "source": "workset", + "message": f"Workset delivery finished with status {run_status}", + }, + ][-120:] + run_payload["written_at"] = datetime.now(timezone.utc).isoformat() + dev_pipeline_write_execution_state(root, execution_manifest_rel, execution_manifest, run_payload) + + manifest["active_run_id"] = run_id + manifest["status"] = run_status + manifest["status_detail"] = f"Workset delivery {run_status}; AI cost ${float(run_payload.get('total_ai_cost_usd') or 0.0):.6f}; changed paths: {', '.join(run_payload.get('changed_paths') or []) or 'none'}" + budget = manifest.get("budget") if isinstance(manifest.get("budget"), dict) else {} + budget["spent_usd"] = float(run_payload.get("total_ai_cost_usd") or 0.0) + budget["cap_usd"] = float(run_payload.get("max_budget_usd") or DEV_PIPELINE_DELIVERY_MAX_BUDGET_USD) + manifest["budget"] = budget + template["budget_spent_usd"] = budget["spent_usd"] + template["budget_cap_usd"] = budget["cap_usd"] + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + "pipeline_workset_delivery_finished", + str(project.get("id") or project_id), + str(template.get("id") or template_id), + { + "execution_run_id": run_id, + "status": run_status, + "workset_receipt": str(run_payload.get("workset_receipt") or ""), + "total_ai_cost_usd": float(run_payload.get("total_ai_cost_usd") or 0.0), + "changed_paths": run_payload.get("changed_paths") or [], + }, + ) + + +def dev_pipeline_spawn_execution_e2e(root: Path, project_id: str, template_id: str, run_id: str) -> None: + run_payload = dev_pipeline_artifact_json(root, f"execution/runs/{run_id}.json") or dev_pipeline_artifact_json(root, "execution/execution_run.json") + if str(run_payload.get("status") or "") != "running": + return + thread = threading.Thread( + target=dev_pipeline_finish_execution_e2e, + args=(root, project_id, template_id, run_id), + name=f"dev-pipeline-delivery-{run_id}", + daemon=True, + ) + thread.start() + + +def dev_pipeline_start_default_issue_run(issue: dict[str, Any]) -> dict[str, Any]: + root = DEV_PIPELINE_STUDIO_ROOT + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + raise AgentWorkAppError(f"Dev Pipeline Studio manifest not found: {dev_pipeline_relative(manifest_path)}") + if dev_pipeline_ensure_builtin_pipelines(manifest): + write_json_path(manifest_path, manifest) + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + project = dev_pipeline_find(projects, DEFAULT_DEV_PIPELINE_PROJECT_ID, DEFAULT_DEV_PIPELINE_PROJECT_ID) + template = dev_pipeline_find(templates, DEFAULT_DEV_PIPELINE_TEMPLATE_ID, DEFAULT_DEV_PIPELINE_TEMPLATE_ID) + if not project or not template: + raise AgentWorkAppError("Default Dev Pipeline Studio route is unavailable") + dev_pipeline_apply_generic_blueprint(template) + issue_id = str(issue.get("id") or "") + subject = str(issue.get("subject") or "") + prompt = str(issue.get("description") or "") + execution_run = dev_pipeline_seed_execution_e2e( + root, + manifest, + project, + template, + { + "triggered_by": f"issue-{issue_id}" if issue_id else "prompt-router", + "issue_id": issue_id, + "issue_subject": subject, + "prompt": prompt, + "message": f"Prompt issue #{issue_id} routed to Hard Proreq Project", + }, + ) + + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + defaults["project_id"] = DEFAULT_DEV_PIPELINE_PROJECT_ID + defaults["template_id"] = DEFAULT_DEV_PIPELINE_TEMPLATE_ID + manifest["defaults"] = defaults + manifest["active_run_id"] = str(execution_run.get("run_id") or "") + manifest["status"] = str(execution_run.get("status") or "running") + manifest["status_detail"] = f"Prompt issue #{issue_id} is routed to Hard Proreq Project" + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + "pipeline_issue_prompt_routed", + DEFAULT_DEV_PIPELINE_PROJECT_ID, + DEFAULT_DEV_PIPELINE_TEMPLATE_ID, + { + "issue_id": issue_id, + "issue_subject": subject, + "execution_run_id": str(execution_run.get("run_id") or ""), + "default_route": True, + }, + ) + dev_pipeline_spawn_execution_e2e(root, DEFAULT_DEV_PIPELINE_PROJECT_ID, DEFAULT_DEV_PIPELINE_TEMPLATE_ID, str(execution_run.get("run_id") or "")) + return { + "project_id": DEFAULT_DEV_PIPELINE_PROJECT_ID, + "template_id": DEFAULT_DEV_PIPELINE_TEMPLATE_ID, + "run_id": str(execution_run.get("run_id") or ""), + "status": str(execution_run.get("status") or "running"), + "default": True, + "url": "/dev-pipeline-studio#pipeline-flow", + } + + +def dev_pipeline_execution_flow( + root: Path, + manifest: dict[str, Any], + project: dict[str, Any], + template: dict[str, Any], + required_inputs: list[dict[str, Any]], + workers: list[dict[str, Any]], + integration_cards: list[dict[str, Any]], + validator_cards: list[dict[str, Any]], + evidence_cards: list[dict[str, Any]], + event_total: int, + budget_spent: float, + budget_cap: float, + selected_run_id: str = "", +) -> dict[str, Any]: + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} + events_rel = str(artifacts.get("events") or "events.ndjson") + execution_manifest_rel = str(template.get("execution_manifest") or "execution/execution_manifest.json") + execution_manifest = dev_pipeline_artifact_json(root, execution_manifest_rel) + execution_run = dev_pipeline_artifact_json(root, "execution/execution_run.json") + expected_pipeline = f"{template.get('id') or 'pipeline'}-{project.get('id') or 'project'}" + if execution_manifest and execution_manifest.get("pipeline") and str(execution_manifest.get("pipeline") or "") != expected_pipeline: + execution_manifest = {} + if execution_run and str(execution_run.get("pipeline") or "") != expected_pipeline: + execution_run = {} + execution_manifest = {} + current_run_id = str(execution_run.get("run_id") or execution_manifest.get("run_id") or "") + selected_run_id = str(selected_run_id or "").strip() + if selected_run_id and selected_run_id != current_run_id and "/" not in selected_run_id and "\\" not in selected_run_id: + selected_run = dev_pipeline_artifact_json(root, f"execution/runs/{selected_run_id}.json") + if selected_run and str(selected_run.get("pipeline") or "") == expected_pipeline: + execution_run = selected_run + current_run_id = str(execution_run.get("run_id") or "") + elif not execution_run: + for run in dev_pipeline_execution_history(root, "", expected_pipeline): + selected_run = dev_pipeline_artifact_json(root, str(run.get("path") or "")) + if selected_run and str(selected_run.get("pipeline") or "") == expected_pipeline: + execution_run = selected_run + current_run_id = str(execution_run.get("run_id") or "") + break + workset_receipt_rel = str(execution_run.get("workset_receipt") or "") + workset_receipt = read_json_path(ROOT_DIR / workset_receipt_rel) if workset_receipt_rel else {} + execution_steps = [item for item in execution_manifest.get("steps", []) if isinstance(item, dict)] + if execution_run.get("steps"): + execution_steps = [item for item in execution_run.get("steps", []) if isinstance(item, dict)] + if not execution_steps: + execution_steps = [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or item.get("id") or ""), + "file": str(item.get("file") or ""), + "status": str(item.get("status") or ""), + "dependencies": [str(value) for value in item.get("dependencies", []) if isinstance(value, str)], + "config": str(item.get("config") or ""), + "receipt": str(item.get("receipt") or ""), + } + for item in template.get("factory_steps", []) + if isinstance(item, dict) + ] + + base_started = ( + parse_iso_datetime(execution_run.get("started_at")) + or parse_iso_datetime(execution_manifest.get("run_started_at")) + or parse_iso_datetime(manifest.get("run_started_at")) + or parse_iso_datetime(execution_manifest.get("written_at")) + or datetime.now(timezone.utc) + ) + base_started = base_started.replace(microsecond=0) + run_started = base_started if execution_run or execution_manifest.get("run_started_at") else base_started - timedelta(seconds=246) + default_stage_durations = { + "input": 31, + "repo": 18, + "blueprint": 24, + "factory": max(72, len(execution_steps) * 9), + "validation": 45, + "handoff": 36, + } + step_defaults = [5, 6, 14, 8, 15, 18, 3, 3] + factory_started = run_started + timedelta(seconds=90) + step_rows: list[dict[str, Any]] = [] + cursor = factory_started + for index, step in enumerate(execution_steps): + status = dev_pipeline_execution_status_label(step.get("status")) + duration = duration_seconds_from_label(step.get("duration"), step_defaults[index] if index < len(step_defaults) else 6) + recorded_started_at = parse_iso_datetime(step.get("started_at")) + recorded_finished_at = parse_iso_datetime(step.get("finished_at")) + started_at = recorded_started_at or cursor + planned_finished_at = started_at + timedelta(seconds=duration) + finished_at = recorded_finished_at or planned_finished_at + cursor = finished_at + show_finished_at = recorded_finished_at + show_started_at = started_at if recorded_started_at or (not execution_run and status != "queued") else None + step_rows.append( + { + "id": str(step.get("id") or f"step-{index + 1}"), + "title": str(step.get("title") or step.get("id") or f"step_{index + 1}"), + "status": status, + "duration_seconds": duration, + "duration": duration_label(duration), + "started": format_run_time(show_started_at, include_date=False), + "finished": format_run_time(show_finished_at, include_date=False), + "started_at": show_started_at.isoformat() if show_started_at else "", + "finished_at": show_finished_at.isoformat() if show_finished_at else "", + "file": str(step.get("file") or ""), + "dependencies": [str(value) for value in step.get("dependencies", []) if isinstance(value, str)], + "config": str(step.get("config") or ""), + "receipt": str(step.get("receipt") or ""), + "command": str(step.get("command") or ""), + "exit_code": step.get("exit_code"), + } + ) + + run_stage_overrides = { + str(stage.get("id") or ""): stage + for stage in execution_run.get("stages", []) + if isinstance(stage, dict) + } + repo_workers = [{**item, "status": item.get("status") or "completed"} for item in workers if str(item.get("stage") or "repo") == "repo"] + blueprint_workers = [{**item, "status": item.get("status") or "completed"} for item in workers if str(item.get("stage") or "") == "blueprint"] + is_workset_delivery = str(execution_run.get("source") or execution_manifest.get("source") or "").startswith("cento-workset") + is_proreq_light = str(template.get("id") or "") == PROREQ_LIGHT_TEMPLATE_ID or str(execution_run.get("source") or "").startswith("cento-proreq-light") + is_hard_proreq = str(template.get("id") or "") == HARD_PROREQ_TEMPLATE_ID or is_proreq_light or str(execution_run.get("source") or "").startswith("cento-hard-proreq") + is_multipipeline = str(template.get("id") or "") == MULTIPIPELINE_TEMPLATE_ID or str(execution_run.get("source") or "").startswith("cento-multipipeline") + factory_title = "4. Sequential ProReq Chain" if is_multipipeline else ("4. ProReq Light Planning" if is_proreq_light else ("4. Proreq Planning" if is_hard_proreq else ("4. Workset Delivery" if is_workset_delivery else "4. Factory Execution"))) + stage_sources = [ + ("input", "1. Input Contract", required_inputs, "input", run_started), + ("repo", "2. Repo Discovery", repo_workers, "repo", run_started + timedelta(seconds=55)), + ("blueprint", "3. Change Blueprint", blueprint_workers, "blueprint", run_started + timedelta(seconds=73)), + ("factory", factory_title, step_rows or integration_cards, "execution", factory_started), + ("validation", "5. Deterministic Validation", validator_cards, "validation", cursor + timedelta(seconds=12)), + ("handoff", "6. Evidence / Handoff", evidence_cards, "handoff", cursor + timedelta(seconds=57)), + ] + stages: list[dict[str, Any]] = [] + for index, (stage_id, title, items, log_key, started_at) in enumerate(stage_sources): + override = run_stage_overrides.get(stage_id, {}) + started_at = parse_iso_datetime(override.get("started_at")) or started_at + recorded_finished_at = parse_iso_datetime(override.get("finished_at")) + finished_at = recorded_finished_at or started_at + timedelta(seconds=default_stage_durations[stage_id]) + duration_seconds = max(0, int(round((finished_at - started_at).total_seconds()))) + if not duration_seconds and stage_id not in {"handoff"} and not recorded_finished_at: + duration_seconds = default_stage_durations[stage_id] + finished_at = started_at + timedelta(seconds=duration_seconds) + status = dev_pipeline_execution_stage_status(items) + if override.get("status"): + status = dev_pipeline_execution_status_label(override.get("status")) + if not override.get("status") and stage_id in {"input", "factory", "validation", "handoff"} and items: + status = "completed" if status != "failed" else status + show_finished_at = recorded_finished_at if status not in {"running", "queued"} else None + count_label = { + "input": f"{len(items)} inputs ready", + "repo": f"{len(items)} contract{'s' if len(items) != 1 else ''}", + "blueprint": f"{len(items)} contract{'s' if len(items) != 1 else ''}", + "factory": f"{len(step_rows)} steps", + "validation": f"{len(items)} validators", + "handoff": f"{len(items)} artifacts", + }[stage_id] + stages.append( + { + "id": stage_id, + "index": index + 1, + "title": title, + "short_title": title.split(". ", 1)[-1], + "status": status, + "count": count_label, + "duration_seconds": duration_seconds, + "duration": duration_label(duration_seconds), + "started": format_run_time(started_at, include_date=False), + "finished": format_run_time(show_finished_at, include_date=False), + "started_at": started_at.isoformat(), + "finished_at": show_finished_at.isoformat() if show_finished_at else "", + "log_key": log_key, + "steps": step_rows if stage_id == "factory" else [], + } + ) + + validation_passed = sum(1 for item in validator_cards if str(item.get("status") or "").lower() in {"passed", "completed"}) + all_items = [*required_inputs, *workers, *step_rows, *validator_cards, *evidence_cards] + overall_status = dev_pipeline_execution_status_label(execution_run.get("status") or execution_manifest.get("status")) + if overall_status == "configured": + overall_status = "completed" if all_items and dev_pipeline_execution_stage_status(all_items) != "failed" else "configured" + stage_finished = max((parse_iso_datetime(stage.get("finished_at")) or run_started for stage in stages), default=run_started) + recorded_finished = parse_iso_datetime(execution_run.get("finished_at")) or parse_iso_datetime(execution_manifest.get("run_finished_at")) or stage_finished + if overall_status in {"running", "queued"}: + run_finished = datetime.now(timezone.utc) + else: + run_finished = max(stage_finished, recorded_finished) + event_rows = read_event_rows(root / events_rel) + logs: list[dict[str, Any]] = [] + for row in execution_run.get("logs", []): + if not isinstance(row, dict): + continue + timestamp = parse_iso_datetime(row.get("timestamp")) or run_finished + logs.append( + { + "time": format_run_time(timestamp, include_date=False), + "stage": str(row.get("stage") or "execution"), + "source": str(row.get("source") or "execution"), + "message": str(row.get("message") or ""), + } + ) + for step in step_rows: + timestamp = parse_iso_datetime(step.get("started_at")) or factory_started + command_detail = f" via {step.get('command')}" if step.get("command") else "" + step_status = str(step.get("status") or "configured") + if step_status == "completed": + message = f"{step['title']} completed with status {step_status}{command_detail}" + elif step_status == "running": + message = f"{step['title']} is running{command_detail}" + else: + message = f"{step['title']} is {step_status}{command_detail}" + logs.append( + { + "time": format_run_time(timestamp, include_date=False), + "stage": "execution", + "source": step["id"], + "message": message, + } + ) + workset_events_rel = str(execution_run.get("workset_events") or workset_receipt.get("events") or "") + if workset_events_rel: + for row in read_event_rows(ROOT_DIR / workset_events_rel, limit=60): + timestamp = parse_iso_datetime(row.get("ts") or row.get("timestamp")) or run_finished + event = str(row.get("event") or "workset_event").replace("_", " ") + logs.append( + { + "time": format_run_time(timestamp, include_date=False), + "stage": "execution", + "source": str(row.get("task_id") or row.get("workset_id") or "workset"), + "message": event, + } + ) + for row in event_rows[-24:]: + timestamp = parse_iso_datetime(row.get("timestamp")) or run_finished + event = str(row.get("event") or "pipeline_event").replace("pipeline_", "").replace("_", " ") + details = row.get("details") if isinstance(row.get("details"), dict) else {} + selected = details.get("selected_integration") or details.get("selected_validator") or details.get("selected_input") or details.get("selected_worker") or "" + logs.append( + { + "time": format_run_time(timestamp, include_date=False), + "stage": "pipeline", + "source": str(selected or row.get("template_id") or "pipeline"), + "message": event, + } + ) + logs.sort(key=lambda item: item.get("time", "")) + + summary_artifacts: list[dict[str, Any]] = [] + for artifact in execution_run.get("artifacts", []): + if not isinstance(artifact, dict): + continue + path = str(artifact.get("path") or "").strip() + if path and all(str(item.get("path") or "") != path for item in summary_artifacts): + summary_artifacts.append( + { + "name": str(artifact.get("name") or Path(path).name), + "path": path, + "size": str(artifact.get("size") or file_size_label(ROOT_DIR / path)), + "exists": bool(artifact.get("exists", (ROOT_DIR / path).exists())), + } + ) + if is_hard_proreq or is_multipipeline: + fallback_artifacts = [ + execution_manifest_rel, + f"execution/runs/{current_run_id}.json" if current_run_id else "execution/execution_run.json", + ] + else: + fallback_artifacts = [ + str(artifacts.get("pipeline_receipt") or "evidence/pipeline_receipt.json"), + str(artifacts.get("evidence_bundle") or "evidence/evidence_bundle.json"), + execution_manifest_rel, + f"execution/runs/{current_run_id}.json" if current_run_id else "execution/execution_run.json", + str(artifacts.get("validation_receipt") or "validation/validation_receipt.json"), + "evidence/handoff_packet.json", + ] + for rel in fallback_artifacts: + clean = str(rel or "").strip() + if not clean or any(str(item.get("path") or "") == clean for item in summary_artifacts): + continue + path = root / clean + summary_artifacts.append( + { + "name": Path(clean).name, + "path": clean, + "size": file_size_label(path), + "exists": path.exists(), + } + ) + + manifest_active_run_id = str(manifest.get("active_run_id") or "") + if manifest_active_run_id and not manifest_active_run_id.startswith(f"{expected_pipeline}-"): + manifest_active_run_id = "" + run_id = str(execution_run.get("run_id") or execution_manifest.get("run_id") or manifest_active_run_id or f"{expected_pipeline}-{run_started.strftime('%Y%m%dT%H%M%SZ')}") + run_is_live = overall_status in {"running", "queued"} + history = dev_pipeline_execution_history(root, current_run_id or run_id, expected_pipeline) + if run_id and all(str(item.get("run_id") or "") != run_id for item in history): + history.insert( + 0, + { + "run_id": run_id, + "status": overall_status, + "started": format_run_time(run_started), + "finished": "In progress" if run_is_live else format_run_time(run_finished), + "duration": duration_label(int((run_finished - run_started).total_seconds())), + "source": str(execution_run.get("source") or execution_manifest.get("source") or "manifest-derived"), + "pipeline": expected_pipeline, + "active": run_id == (current_run_id or run_id), + "path": dev_pipeline_relative(root / "execution" / "execution_run.json"), + "artifact_count": len(summary_artifacts), + "ready_artifact_count": len([item for item in summary_artifacts if bool(item.get("exists", True))]), + }, + ) + return { + "run_id": run_id, + "active_run_id": current_run_id or run_id, + "is_active_run": run_id == (current_run_id or run_id), + "pipeline": expected_pipeline, + "status": overall_status, + "source": str(execution_run.get("source") or execution_manifest.get("source") or "manifest-derived"), + "started": format_run_time(run_started), + "finished": "In progress" if run_is_live else format_run_time(run_finished), + "duration": duration_label(int((run_finished - run_started).total_seconds())), + "triggered_by": str(execution_run.get("triggered_by") or manifest.get("triggered_by") or "jenkins-bot"), + "run_mode": str(execution_run.get("apply_mode") or manifest.get("run_mode") or "Normal"), + "evidence_policy": "Required", + "manifest_version": str(manifest.get("version") or execution_manifest.get("schema_version") or "cento.execution_manifest.v1"), + "event_count": event_total, + "budget": f"${budget_spent:.2f} of ${budget_cap:.2f}", + "stages": stages, + "steps": step_rows, + "selected_stage_id": "factory" if step_rows else (stages[0]["id"] if stages else ""), + "logs": logs[-80:], + "artifacts": summary_artifacts, + "facts": [item for item in execution_run.get("facts", []) if isinstance(item, dict)], + "readiness_errors": [str(item) for item in execution_run.get("readiness_errors", []) if isinstance(item, str)], + "target_paths": [str(item) for item in execution_run.get("target_paths", []) if isinstance(item, str)], + "changed_paths": [str(item) for item in execution_run.get("changed_paths", []) if isinstance(item, str)], + "total_ai_cost_usd": execution_run.get("total_ai_cost_usd", workset_receipt.get("total_cost_usd") if workset_receipt else None), + "workset_receipt": workset_receipt_rel, + "parallel": dev_pipeline_execution_parallel_summary(execution_run, workset_receipt), + "history": history, + "validation_results": { + "passed": validation_passed, + "total": len(validator_cards), + "items": [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or "Validator"), + "status": dev_pipeline_execution_status_label(item.get("status")), + "duration": duration_label(duration_seconds_from_label(item.get("duration"), 45 if index == 0 else 32 if index == 1 else 28)), + } + for index, item in enumerate(validator_cards) + ], + }, + } + + +def dev_pipeline_add_stage_element(root: Path, manifest: dict[str, Any], project: dict[str, Any], template: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + element_type = dev_pipeline_stage_element_type(payload.get("element_type")) + template_id = str(template.get("id") or "pipeline") + project_root = str(project.get("owned_root") or "workspace/runs/generic-task/outputs") + if element_type == "input": + inputs = dev_pipeline_required_inputs(template.get("required_inputs")) or dev_pipeline_default_required_inputs(template_id) + element_id = dev_pipeline_unique_id(inputs, "new-input") + item = { + "id": element_id, + "title": "New input", + "detail": "Describe the operator input this pipeline requires", + "kind": "details", + "input_type": "details", + "format": "markdown", + "status": "missing", + "required": True, + } + inputs.append(item) + template["required_inputs"] = dev_pipeline_write_input_manifests(root, manifest, project, template, inputs) + return {"element_type": element_type, "element_id": element_id, "stage": "input", "title": item["title"]} + + if element_type == "worker": + workers = [item for item in template.get("workers", []) if isinstance(item, dict)] + stage = dev_pipeline_stage_kind(payload.get("element_stage")) + base_id = "blueprint-contract" if stage == "blueprint" else "repo-contract" + element_id = dev_pipeline_unique_id(workers, base_id) + file_name = f"{element_id}.json" + item = { + "id": element_id, + "title": "Blueprint contract" if stage == "blueprint" else "Repo contract", + "file": file_name, + "description": "Describe the deterministic contract this stage must produce", + "stage": stage, + "dependencies": ["repo-context"] if stage == "blueprint" and any(str(worker.get("id") or "") == "repo-context" for worker in workers) else [], + "manifest": f"workers/{template_id}_{element_id}.json", + "integration_receipt": f"integration_receipts/{template_id}_{element_id}.json", + } + workers.append(item) + template["workers"] = workers + write_json_path(dev_pipeline_root_path(root, str(item["manifest"])), dev_pipeline_synthesized_worker_manifest(project, template, item)) + return {"element_type": element_type, "element_id": element_id, "stage": stage, "title": item["title"]} + + if element_type == "integration": + factory_steps = [item for item in template.get("factory_steps", []) if isinstance(item, dict)] + element_id = dev_pipeline_unique_id(factory_steps, "new-execution-step") + dependencies = [str(factory_steps[-1].get("id") or "")] if factory_steps else [] + item = { + "id": element_id, + "title": "new_execution_step", + "file": f"{element_id}.json", + "status": "queued", + "mode": "deterministic", + "dependencies": [dependency for dependency in dependencies if dependency], + "artifacts": [f"{project_root}/{element_id}.json"], + "gates": ["Previous dependency receipts are accepted", "No owned-path conflict"], + "rollback_plan": ["Keep prior receipt until this step applies successfully"], + } + factory_steps.append(item) + template["factory_steps"] = factory_steps + config = dev_pipeline_integration_config(root, project, template, item, item) + dev_pipeline_write_factory_step_outputs(root, manifest, project, template, config) + return {"element_type": element_type, "element_id": element_id, "stage": "integration", "title": item["title"]} + + if element_type == "validation": + validators = [item for item in template.get("validators", []) if isinstance(item, dict)] + element_id = dev_pipeline_unique_id(validators, "new-validator") + item = { + "id": element_id, + "title": "New Validator", + "file": f"{element_id}_receipt.json", + "receipt": f"validation/{element_id}_receipt.json", + "config": f"validation/validator_configs/{element_id}.json", + "mode": "commands", + "status": "configured", + "blocking": True, + } + validators.append(item) + template["validators"] = validators + config = dev_pipeline_validator_config( + root, + project, + template, + item, + { + **item, + "tier": str(template.get("validation_tier") or "smoke-plus"), + "summary": "Configure the deterministic checks this validator must run.", + "commands": ["python3 -m json.tool workspace/runs/dev-pipeline-studio/docs-pages/latest/pipeline_manifest.json"], + "evidence": ["pipeline_manifest.json"], + "gates": ["Blocking validator prevents handoff until resolved"], + "schema_paths": ["pipeline_manifest.json"], + }, + ) + dev_pipeline_write_validation_outputs(root, manifest, project, template, config) + return {"element_type": element_type, "element_id": element_id, "stage": "validation", "title": item["title"]} + + evidence_items = [item for item in template.get("evidence_artifacts", []) if isinstance(item, dict)] + base_ids = {"pipeline-receipt", "events", "evidence-bundle", "budget", "taskstream"} + element_id = dev_pipeline_unique_id([*evidence_items, *[{"id": base_id} for base_id in base_ids]], "new-evidence") + item = { + "id": element_id, + "title": "New Evidence", + "file": f"{element_id}.json", + "status": "Configured", + "kind": "artifact", + "path": f"evidence/{element_id}.json", + "required_sources": ["pipeline_manifest.json", "validation/validation_receipt.json"], + "publish_policy": "Attach to evidence bundle before Taskstream review", + "retention_policy": "Keep with the pipeline run artifacts", + "review_notes": "", + } + evidence_items.append(item) + template["evidence_artifacts"] = evidence_items + disabled = [dev_pipeline_slug(str(value), "") for value in template.get("evidence_disabled", []) if str(value).strip()] + template["evidence_disabled"] = [value for value in disabled if value != element_id] + config = dev_pipeline_evidence_config(root, manifest, project, template, item, item) + dev_pipeline_write_evidence_outputs(root, manifest, project, template, config) + return {"element_type": element_type, "element_id": element_id, "stage": "evidence", "title": item["title"]} + + +def dev_pipeline_delete_stage_element(root: Path, manifest: dict[str, Any], project: dict[str, Any], template: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + element_type = dev_pipeline_stage_element_type(payload.get("element_type")) + element_id = dev_pipeline_slug(dev_pipeline_text(payload.get("element_id"), ""), "") + if not element_id: + raise AgentWorkAppError("element_id is required for delete_element") + + if element_type == "input": + inputs = [item for item in dev_pipeline_required_inputs(template.get("required_inputs")) if str(item.get("id") or "") != element_id] + template["required_inputs"] = dev_pipeline_write_input_manifests(root, manifest, project, template, inputs) + elif element_type == "worker": + workers = [item for item in template.get("workers", []) if isinstance(item, dict) and str(item.get("id") or "") != element_id] + template["workers"] = workers + if str(template.get("selected_worker") or "") == element_id: + template["selected_worker"] = str(workers[0].get("id") or "") if workers else "" + elif element_type == "integration": + template["factory_steps"] = [ + item for item in template.get("factory_steps", []) + if isinstance(item, dict) and str(item.get("id") or "") != element_id + ] + elif element_type == "validation": + template["validators"] = [ + item for item in template.get("validators", []) + if isinstance(item, dict) and str(item.get("id") or "") != element_id + ] + else: + evidence_items = [ + item for item in template.get("evidence_artifacts", []) + if isinstance(item, dict) and dev_pipeline_slug(str(item.get("id") or ""), "") != element_id + ] + removed_custom = len(evidence_items) != len([item for item in template.get("evidence_artifacts", []) if isinstance(item, dict)]) + template["evidence_artifacts"] = evidence_items + if not removed_custom: + disabled = { + dev_pipeline_slug(str(value), "") + for value in template.get("evidence_disabled", []) + if str(value).strip() + } + disabled.add(element_id) + template["evidence_disabled"] = sorted(value for value in disabled if value) + return {"element_type": element_type, "element_id": element_id, "deleted": True} + + +def dev_pipeline_update(payload: dict[str, Any]) -> dict[str, Any]: + root = DEV_PIPELINE_STUDIO_ROOT + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + raise AgentWorkAppError(f"Dev Pipeline Studio manifest not found: {dev_pipeline_relative(manifest_path)}") + if dev_pipeline_ensure_builtin_pipelines(manifest): + write_json_path(manifest_path, manifest) + + action = str(payload.get("action") or "save").strip() or "save" + if action not in {"save", "select_worker", "duplicate", "new", "save_input", "save_validation", "run_validation", "save_integration", "save_evidence", "add_element", "delete_element", "run_execution_e2e", "run_delivery"}: + raise AgentWorkAppError(f"Unsupported Dev Pipeline Studio action: {action}") + + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + project_id = str(payload.get("project_id") or defaults.get("project_id") or "").strip() + template_id = str(payload.get("template_id") or defaults.get("template_id") or "").strip() + project = dev_pipeline_find(projects, project_id, str(defaults.get("project_id") or "")) + template = dev_pipeline_find(templates, template_id, str(defaults.get("template_id") or "")) + if not project or not template: + raise AgentWorkAppError("A valid project and template are required") + dev_pipeline_apply_generic_blueprint(template) + + project_payload = payload.get("project") if isinstance(payload.get("project"), dict) else {} + template_payload = payload.get("template") if isinstance(payload.get("template"), dict) else {} + + if action in {"duplicate", "new"}: + label_override = "" + if action == "new": + label_override = dev_pipeline_text(template_payload.get("label"), "New generic task template") + elif "label" in template_payload: + label_override = f"{dev_pipeline_text(template_payload.get('label'), str(template.get('label') or 'Template'))} copy" + template = dev_pipeline_duplicate_template(root, manifest, project, template, label_override=label_override) + template_id = str(template.get("id") or "") + + if "label" in project_payload: + project["label"] = dev_pipeline_text(project_payload.get("label"), str(project.get("label") or "")) + if "surface" in project_payload: + project["surface"] = dev_pipeline_text(project_payload.get("surface"), str(project.get("surface") or "")) + if "surface_value" in project_payload: + project["surface_value"] = dev_pipeline_text(project_payload.get("surface_value"), str(project.get("surface_value") or "")) + if "owned_root" in project_payload: + project["owned_root"] = dev_pipeline_text(project_payload.get("owned_root"), str(project.get("owned_root") or "")) + if "read_paths" in project_payload or "read_paths_text" in project_payload: + raw_read_paths = project_payload.get("read_paths", project_payload.get("read_paths_text")) + project["read_paths"] = dev_pipeline_text_list(raw_read_paths, [str(value) for value in project.get("read_paths", []) if isinstance(value, str)]) + + if "label" in template_payload: + template["label"] = dev_pipeline_text(template_payload.get("label"), str(template.get("label") or "")) + if "detail" in template_payload: + template["detail"] = dev_pipeline_text(template_payload.get("detail"), str(template.get("detail") or "")) + if "description" in template_payload: + template["description"] = dev_pipeline_text(template_payload.get("description"), str(template.get("description") or "")) + if "tagline" in template_payload: + template["tagline"] = dev_pipeline_text(template_payload.get("tagline"), str(template.get("tagline") or "")) + if "validation_tier" in template_payload: + template["validation_tier"] = dev_pipeline_text(template_payload.get("validation_tier"), str(template.get("validation_tier") or "")) + if "risk" in template_payload: + template["risk"] = dev_pipeline_text(template_payload.get("risk"), str(template.get("risk") or "")) + if "worker_stage_label" in template_payload: + template["worker_stage_label"] = dev_pipeline_text(template_payload.get("worker_stage_label"), str(template.get("worker_stage_label") or "")) + if "factory_stage_label" in template_payload: + template["factory_stage_label"] = dev_pipeline_text(template_payload.get("factory_stage_label"), str(template.get("factory_stage_label") or "")) + if "execution_model" in template_payload: + template["execution_model"] = dev_pipeline_text(template_payload.get("execution_model"), str(template.get("execution_model") or "")) + if "required_inputs" in template_payload: + template["required_inputs"] = dev_pipeline_write_input_manifests( + root, + manifest, + project, + template, + dev_pipeline_required_inputs(template_payload.get("required_inputs"), template.get("required_inputs")), + ) + template["budget_spent_usd"] = dev_pipeline_float(template_payload.get("budget_spent_usd"), float(template.get("budget_spent_usd", (manifest.get("budget") or {}).get("spent_usd", 0)) or 0)) + template["budget_cap_usd"] = dev_pipeline_float(template_payload.get("budget_cap_usd"), float(template.get("budget_cap_usd", (manifest.get("budget") or {}).get("cap_usd", 0)) or 0)) + + workers = [item for item in template.get("workers", []) if isinstance(item, dict)] + requested_worker = dev_pipeline_text(template_payload.get("selected_worker"), str(template.get("selected_worker") or "")) + if action == "select_worker": + requested_worker = dev_pipeline_text(payload.get("worker_id"), requested_worker) + if requested_worker and any(str(worker.get("id") or "") == requested_worker for worker in workers): + template["selected_worker"] = requested_worker + elif workers and not template.get("selected_worker"): + template["selected_worker"] = str(workers[0].get("id") or "") + + input_config_payload = payload.get("input_config") + saved_input_id = "" + if action == "save_input": + if not isinstance(input_config_payload, dict): + raise AgentWorkAppError("input_config is required for save_input") + existing_inputs = dev_pipeline_required_inputs(template.get("required_inputs")) or dev_pipeline_default_required_inputs(str(template.get("id") or "")) + normalized_inputs = dev_pipeline_required_inputs([input_config_payload], existing_inputs) + if not normalized_inputs: + raise AgentWorkAppError("input_config must include an input title") + updated_input = normalized_inputs[0] + saved_input_id = str(updated_input.get("id") or "") + merged_inputs: list[dict[str, Any]] = [] + replaced = False + for item in existing_inputs: + if str(item.get("id") or "") == saved_input_id: + merged_inputs.append(updated_input) + replaced = True + else: + merged_inputs.append(item) + if not replaced: + merged_inputs.append(updated_input) + template["required_inputs"] = dev_pipeline_write_input_manifests( + root, + manifest, + project, + template, + merged_inputs, + ) + + worker_manifest_payload = payload.get("worker_manifest") + if isinstance(worker_manifest_payload, dict): + selected_worker_id = str(template.get("selected_worker") or "") + selected_worker = next((item for item in workers if str(item.get("id") or "") == selected_worker_id), workers[0] if workers else {}) + selected_worker_id = str(selected_worker.get("id") or selected_worker_id) + if selected_worker_id: + template["selected_worker"] = selected_worker_id + manifest_rel = str(selected_worker.get("manifest") or f"workers/{template.get('id')}_{selected_worker_id}.json") + selected_worker["manifest"] = manifest_rel + worker_manifest_payload = deepcopy(worker_manifest_payload) + worker_manifest_payload["schema_version"] = str(worker_manifest_payload.get("schema_version") or "cento.worker_manifest.v1") + worker_manifest_payload["project"] = str(project.get("id") or "") + worker_manifest_payload["template_id"] = str(template.get("id") or "") + worker_manifest_payload["task_id"] = selected_worker_id + if "validation" not in worker_manifest_payload or not isinstance(worker_manifest_payload.get("validation"), dict): + worker_manifest_payload["validation"] = {"tier": str(template.get("validation_tier") or "smoke")} + write_json_path(dev_pipeline_root_path(root, manifest_rel), worker_manifest_payload) + + validation_config_payload = payload.get("validation_config") + if action in {"save_validation", "run_validation"}: + if not isinstance(validation_config_payload, dict): + raise AgentWorkAppError("validation_config is required for validation actions") + validators = [item for item in template.get("validators", []) if isinstance(item, dict)] + requested_validator_id = dev_pipeline_slug(dev_pipeline_text(validation_config_payload.get("id"), ""), "validator") + selected_validator = next((item for item in validators if str(item.get("id") or "") == requested_validator_id), {"id": requested_validator_id}) + config = dev_pipeline_validator_config(root, project, template, selected_validator, validation_config_payload) + if action == "run_validation": + config = dev_pipeline_execute_validation(root, config, dev_pipeline_text(payload.get("validation_run_mode"), str(config.get("mode") or "commands"))) + template["validation_tier"] = str(config.get("tier") or template.get("validation_tier") or "") + dev_pipeline_write_validation_outputs(root, manifest, project, template, config) + + integration_config_payload = payload.get("integration_config") + if action == "save_integration": + if not isinstance(integration_config_payload, dict): + raise AgentWorkAppError("integration_config is required for save_integration") + workers = [item for item in template.get("workers", []) if isinstance(item, dict)] + factory_steps = [item for item in template.get("factory_steps", []) if isinstance(item, dict)] + requested_integration_id = dev_pipeline_slug(dev_pipeline_text(integration_config_payload.get("id"), ""), "integration") + selected_factory_step = next((item for item in factory_steps if str(item.get("id") or "") == requested_integration_id), None) + if selected_factory_step is not None: + config = dev_pipeline_integration_config(root, project, template, selected_factory_step, integration_config_payload) + dev_pipeline_write_factory_step_outputs(root, manifest, project, template, config) + else: + selected_integration = next((item for item in workers if str(item.get("id") or "") == requested_integration_id), {"id": requested_integration_id}) + config = dev_pipeline_integration_config(root, project, template, selected_integration, integration_config_payload) + dev_pipeline_write_integration_outputs(root, manifest, project, template, config) + + evidence_config_payload = payload.get("evidence_config") + if action == "save_evidence": + if not isinstance(evidence_config_payload, dict): + raise AgentWorkAppError("evidence_config is required for save_evidence") + config = dev_pipeline_evidence_config(root, manifest, project, template, evidence_config_payload, evidence_config_payload) + dev_pipeline_write_evidence_outputs(root, manifest, project, template, config) + + mutation: dict[str, Any] = {} + if action == "add_element": + mutation = dev_pipeline_add_stage_element(root, manifest, project, template, payload) + elif action == "delete_element": + mutation = dev_pipeline_delete_stage_element(root, manifest, project, template, payload) + + execution_run: dict[str, Any] = {} + if action in {"run_execution_e2e", "run_delivery"}: + execution_run = dev_pipeline_seed_execution_e2e(root, manifest, project, template) + + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + defaults["project_id"] = str(project.get("id") or "") + defaults["template_id"] = str(template.get("id") or "") + manifest["defaults"] = defaults + manifest["active_run_id"] = f"{template.get('id')}-{project.get('id')}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" + manifest["status"] = "configured" if action in {"save_input", "save_validation", "save_integration", "save_evidence", "add_element", "delete_element"} else "healthy" + if action == "run_validation": + manifest["status"] = str(config.get("status") or "configured") if "config" in locals() else "configured" + manifest["status_detail"] = "Validation tab executed; validator receipts and run results are in sync" + elif action in {"run_execution_e2e", "run_delivery"}: + manifest["active_run_id"] = str(execution_run.get("run_id") or manifest.get("active_run_id") or "") + manifest["status"] = str(execution_run.get("status") or "running") + if str(template.get("id") or "") == HARD_PROREQ_TEMPLATE_ID: + manifest["status_detail"] = "Hard proreq pipeline is generating Cento context, a muted UI screenshot request, GPT pro backend schema request, backend work, integration, validation, and evidence" + elif str(template.get("id") or "") == MULTIPIPELINE_TEMPLATE_ID: + manifest["status_detail"] = "Multipipeline ProReq chain is scheduling four sequential ProReq request passes, UI screenshot guidance, Pro request, roadmap, and evidence" + elif manifest["status"] == "blocked": + manifest["status_detail"] = "Workset delivery is blocked by readiness checks; execution_run.json lists the exact blocker" + else: + manifest["status_detail"] = "Workset delivery is running through cento workset execute with api-openai and direct worktree apply" + elif action == "save_input": + manifest["status_detail"] = "Input contract saved; input manifests are in sync" + elif action == "save_validation": + manifest["status_detail"] = "Validation configuration saved; validator manifests and receipts are in sync" + elif action == "save_integration": + manifest["status_detail"] = "Integration configuration saved; integration lane manifest and receipts are in sync" + elif action == "save_evidence": + manifest["status_detail"] = "Evidence configuration saved; evidence artifact outputs are in sync" + elif action == "add_element": + manifest["status_detail"] = f"Added {mutation.get('element_type', 'pipeline')} element {mutation.get('element_id', '')}; pipeline manifest state is in sync" + elif action == "delete_element": + manifest["status_detail"] = f"Removed {mutation.get('element_type', 'pipeline')} element {mutation.get('element_id', '')}; pipeline manifest state is in sync" + else: + manifest["status_detail"] = "Editable manifest saved; worker contract and pipeline execution metadata are in sync" + + dev_pipeline_apply_generic_blueprint(template) + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + f"pipeline_{action}", + str(project.get("id") or ""), + str(template.get("id") or ""), + { + "selected_worker": str(template.get("selected_worker") or ""), + "selected_validator": str((validation_config_payload or {}).get("id") or "") if isinstance(validation_config_payload, dict) else "", + "validation_run_mode": str(payload.get("validation_run_mode") or "") if action == "run_validation" else "", + "selected_integration": str((integration_config_payload or {}).get("id") or "") if isinstance(integration_config_payload, dict) else "", + "selected_evidence": str((evidence_config_payload or {}).get("id") or "") if isinstance(evidence_config_payload, dict) else "", + "selected_input": saved_input_id, + "mutation": mutation, + "execution_run_id": str(execution_run.get("run_id") or ""), + }, + ) + if action in {"run_execution_e2e", "run_delivery"} and execution_run.get("run_id") and str(execution_run.get("status") or "") == "running": + dev_pipeline_spawn_execution_e2e(root, str(project.get("id") or ""), str(template.get("id") or ""), str(execution_run.get("run_id") or "")) + state = dev_pipeline_studio_state(project_id=str(project.get("id") or ""), template_id=str(template.get("id") or "")) + if mutation: + state["mutation"] = mutation + return state + + +def dev_pipeline_studio_state(project_id: str = "", template_id: str = "", run_id: str = "") -> dict[str, Any]: + root = DEV_PIPELINE_STUDIO_ROOT + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + raise AgentWorkAppError(f"Dev Pipeline Studio manifest not found: {dev_pipeline_relative(manifest_path)}") + if dev_pipeline_ensure_builtin_pipelines(manifest): + write_json_path(manifest_path, manifest) + + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + for item in templates: + dev_pipeline_apply_generic_blueprint(item) + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + project = dev_pipeline_find(projects, project_id, str(defaults.get("project_id") or "")) + template = dev_pipeline_find(templates, template_id, str(defaults.get("template_id") or "")) + workers = [item for item in template.get("workers", []) if isinstance(item, dict)] + raw_factory_steps = template.get("factory_steps") + factory_steps_explicit = isinstance(raw_factory_steps, list) + factory_steps = [item for item in raw_factory_steps if isinstance(item, dict)] if factory_steps_explicit else [] + selected_worker_id = str(template.get("selected_worker") or (workers[0].get("id") if workers else "")) + selected_worker = next((item for item in workers if str(item.get("id") or "") == selected_worker_id), workers[0] if workers else {}) + worker_manifest, worker_manifest_rel = dev_pipeline_worker_manifest(project, template, selected_worker, root) + + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} + validation_receipt_rel = str(artifacts.get("validation_receipt") or "validation/validation_receipt.json") + validation_receipt = dev_pipeline_artifact_json(root, validation_receipt_rel) + evidence_bundle_rel = str(artifacts.get("evidence_bundle") or "evidence/evidence_bundle.json") + budget_receipt_rel = str(artifacts.get("budget_receipt") or "evidence/budget_receipt.json") + pipeline_receipt_rel = str(artifacts.get("pipeline_receipt") or "evidence/pipeline_receipt.json") + taskstream_evidence_rel = str(artifacts.get("taskstream_evidence") or "evidence/taskstream_evidence.json") + events_rel = str(artifacts.get("events") or "events.ndjson") + event_total = event_count(root / events_rel) + + budget_spent = float(template.get("budget_spent_usd", (manifest.get("budget") or {}).get("spent_usd", 0)) or 0) + budget_cap = float(template.get("budget_cap_usd", (manifest.get("budget") or {}).get("cap_usd", 0)) or 0) + tasks_completed = int(template.get("tasks_completed", len(workers)) or 0) + tasks_total = int(template.get("tasks_total", max(len(workers), tasks_completed)) or 0) + execution_model = str(template.get("execution_model") or ("ordered" if str(template.get("id") or "") == "generic-task" else "parallel")) + worker_stage_label = str(template.get("worker_stage_label") or ("2. Task Execution" if execution_model == "ordered" else "2. Workers (Parallel)")) + factory_stage_label = str(template.get("factory_stage_label") or "4. Factory Execution") + worker_count_label = f"{len(workers)} automation contracts" if execution_model == "ordered" else f"{len(workers)} workers" + required_inputs = dev_pipeline_template_required_inputs(template) + missing_required_inputs = [ + item for item in required_inputs + if item.get("required") and str(item.get("status") or "") == "missing" + ] + input_count_label = f"{len(missing_required_inputs)} missing / {len(required_inputs)} inputs" if missing_required_inputs else f"{len(required_inputs)} inputs ready" + validation_status = str(validation_receipt.get("status") or manifest.get("status") or "unknown") + status_label = "Healthy" if validation_status in {"passed", "healthy", "completed"} else title_status(validation_status, "Unknown") + + worker_cards: list[dict[str, Any]] = [] + integration_cards: list[dict[str, Any]] = [] + for index, worker in enumerate(workers, start=1): + worker_id = str(worker.get("id") or "") + file_name = str(worker.get("file") or f"{worker_id}.json") + receipt_rel = str(worker.get("integration_receipt") or f"integration_receipts/{template.get('id')}_{worker_id}.json") + integration_config = dev_pipeline_integration_config(root, project, template, worker) + receipt = dev_pipeline_artifact_json(root, receipt_rel) + receipt_status = str(integration_config.get("status") or receipt.get("status") or "accepted") + stage = str(worker.get("stage") or ("blueprint" if worker_id in {"change-blueprint", "plan"} else "repo")) + dependencies = [str(value) for value in worker.get("dependencies", []) if isinstance(value, str)] + if dependencies: + worker_detail = f"{file_name} after {', '.join(dependencies)}" + elif execution_model == "ordered": + worker_detail = f"{file_name} step {index}/{len(workers)}" + else: + worker_detail = f"{file_name} parallel" + worker_cards.append( + { + "id": worker_id, + "title": str(worker.get("title") or worker_id), + "file": file_name, + "detail": worker_detail, + "status": "Completed", + "selected": worker_id == selected_worker_id, + "stage": stage, + } + ) + + if not factory_steps and not factory_steps_explicit: + integration_cards.append( + { + "id": worker_id, + "title": str(integration_config.get("title") or f"Integrate: {file_name}"), + "file": "integration_receipt.json", + "status": title_status(receipt_status, "Accepted"), + "path": dev_pipeline_relative(root / receipt_rel) if (root / receipt_rel).exists() else "", + "summary": str(integration_config.get("apply_policy") or ""), + "mode": str(integration_config.get("mode") or "dependency-order"), + "apply_policy": str(integration_config.get("apply_policy") or ""), + "conflict_policy": str(integration_config.get("conflict_policy") or ""), + "dependencies": [str(value) for value in integration_config.get("dependencies", []) if isinstance(value, str)], + "artifacts": [str(value) for value in integration_config.get("artifacts", []) if isinstance(value, str)], + "gates": [str(value) for value in integration_config.get("gates", []) if isinstance(value, str)], + "rollback_plan": [str(value) for value in integration_config.get("rollback_plan", []) if isinstance(value, str)], + "receipt": receipt_rel, + "config": dev_pipeline_relative(root / str(integration_config.get("config_path") or f"integration/configs/{worker_id}.json")), + } + ) + + if factory_steps: + for step in factory_steps: + step_id = str(step.get("id") or "") + file_name = str(step.get("file") or f"{step_id}.json") + receipt_rel = str(step.get("integration_receipt") or f"integration_receipts/{template.get('id')}_{step_id}.json") + integration_config = dev_pipeline_integration_config(root, project, template, step) + receipt = dev_pipeline_artifact_json(root, receipt_rel) + receipt_status = dev_pipeline_integration_status(step.get("status"), str(integration_config.get("status") or receipt.get("status") or "queued")) + integration_cards.append( + { + "id": step_id, + "title": str(step.get("title") or integration_config.get("title") or step_id), + "file": file_name, + "status": title_status(receipt_status, "Accepted"), + "path": dev_pipeline_relative(root / receipt_rel) if (root / receipt_rel).exists() else "", + "summary": str(integration_config.get("apply_policy") or ""), + "mode": str(step.get("mode") or integration_config.get("mode") or "deterministic"), + "apply_policy": str(integration_config.get("apply_policy") or ""), + "conflict_policy": str(integration_config.get("conflict_policy") or ""), + "dependencies": [str(value) for value in integration_config.get("dependencies", []) if isinstance(value, str)], + "artifacts": [str(value) for value in integration_config.get("artifacts", []) if isinstance(value, str)], + "gates": [str(value) for value in integration_config.get("gates", []) if isinstance(value, str)], + "rollback_plan": [str(value) for value in integration_config.get("rollback_plan", []) if isinstance(value, str)], + "receipt": receipt_rel, + "config": dev_pipeline_relative(root / str(integration_config.get("config_path") or f"integration/configs/{step_id}.json")), + } + ) + + validator_cards: list[dict[str, Any]] = [] + for item in [entry for entry in template.get("validators", []) if isinstance(entry, dict)]: + receipt_rel = str(item.get("receipt") or "") + config_rel = str(item.get("config") or f"validation/validator_configs/{item.get('id') or 'validator'}.json") + config = dev_pipeline_validator_config(root, project, template, item) + receipt = dev_pipeline_artifact_json(root, receipt_rel) + receipt_status = str(config.get("status") or receipt.get("status") or item.get("status") or "passed") + validator_cards.append( + { + "id": str(item.get("id") or ""), + "title": str(config.get("title") or item.get("title") or item.get("id") or "Validator"), + "file": str(item.get("file") or Path(receipt_rel).name or "receipt.json"), + "status": title_status(receipt_status, "Passed"), + "path": dev_pipeline_relative(root / receipt_rel) if receipt_rel else "", + "summary": str(config.get("summary") or receipt.get("summary") or ""), + "mode": str(config.get("mode") or item.get("mode") or "commands"), + "tier": str(config.get("tier") or template.get("validation_tier") or ""), + "commands": [str(value) for value in config.get("commands", []) if isinstance(value, str)], + "evidence": [str(value) for value in config.get("evidence", []) if isinstance(value, str)], + "gates": [str(value) for value in config.get("gates", []) if isinstance(value, str)], + "schema_paths": [str(value) for value in config.get("schema_paths", []) if isinstance(value, str)], + "blocking": bool(config.get("blocking", True)), + "last_run_mode": str(config.get("last_run_mode") or ""), + "last_run_status": str(config.get("last_run_status") or ""), + "executed_at": str(config.get("executed_at") or ""), + "results": config.get("results") if isinstance(config.get("results"), dict) else {}, + "receipt": receipt_rel, + "config": dev_pipeline_relative(root / config_rel), + } ) - if manager.returncode == 0: - manager_payload = json.loads(manager.stdout) - if isinstance(manager_payload, dict) and isinstance(manager_payload.get("summary"), dict): - manager_summary = manager_payload["summary"] - except Exception: - manager_summary = {} - payload["summary"] = { - "live": len(live), - "stale": len(stale), - "actionable_stale": int(manager_summary.get("actionable_stale", len(stale)) or 0), - "historical_stale": int(manager_summary.get("historical_stale", 0) or 0), - "archived": int(manager_summary.get("archived", 0) or 0), - "manual": int(manager_summary.get("manual", 0) or 0), - "risk_count": int(manager_summary.get("risk_count", 0) or 0), - "by_pool": by_pool, - "targets": {"builder": 4, "validator": 3, "small": 3, "coordinator": 1}, - } - return payload + evidence_cards: list[dict[str, Any]] = [] + for item in dev_pipeline_base_evidence_cards(root, manifest, template, event_total, budget_spent, budget_cap): + config = dev_pipeline_evidence_config(root, manifest, project, template, item) + config_saved = (root / str(config.get("config_path") or "")).exists() + evidence_cards.append( + { + "id": str(config.get("id") or item.get("id") or ""), + "title": str(config.get("title") or item.get("title") or ""), + "file": str(item.get("file") or Path(str(config.get("path") or "")).name), + "status": title_status(str(config.get("status") or ""), "Configured") if config_saved else str(item.get("status") or title_status(str(config.get("status") or ""), "Configured")), + "state": str(config.get("status") or "configured"), + "kind": str(config.get("kind") or ""), + "path": str(config.get("path") or item.get("path") or ""), + "config": dev_pipeline_relative(root / str(config.get("config_path") or "")), + "required_sources": [str(value) for value in config.get("required_sources", []) if isinstance(value, str)], + "publish_policy": str(config.get("publish_policy") or ""), + "retention_policy": str(config.get("retention_policy") or ""), + "review_notes": str(config.get("review_notes") or ""), + "base": bool(item.get("base", False)), + } + ) -def read_json_path(path: Path) -> dict[str, Any]: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - return payload if isinstance(payload, dict) else {} + return { + "schema_version": "cento.dev_pipeline_studio_state.v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "source_manifest": dev_pipeline_relative(manifest_path), + "root": dev_pipeline_relative(root), + "projects": [ + { + "id": str(item.get("id") or ""), + "label": str(item.get("label") or item.get("id") or ""), + "surface": str(item.get("surface") or ""), + "surface_value": str(item.get("surface_value") or ""), + "owned_root": str(item.get("owned_root") or ""), + "read_paths": [str(value) for value in item.get("read_paths", []) if isinstance(value, str)], + } + for item in projects + ], + "templates": [ + { + "id": str(item.get("id") or ""), + "label": str(item.get("label") or item.get("id") or ""), + "detail": str(item.get("detail") or ""), + "description": str(item.get("description") or ""), + "tagline": str(item.get("tagline") or ""), + "slug": str(item.get("slug") or item.get("id") or ""), + "worker_type": str(item.get("worker_type") or "pipeline_worker"), + "validation_tier": str(item.get("validation_tier") or ""), + "risk": str(item.get("risk") or ""), + "budget_spent_usd": float(item.get("budget_spent_usd", 0) or 0), + "budget_cap_usd": float(item.get("budget_cap_usd", 0) or 0), + "max_parallel": int(item.get("max_parallel", 1) or 1), + "selected_worker": str(item.get("selected_worker") or ""), + "execution_model": str(item.get("execution_model") or ("ordered" if str(item.get("id") or "") == "generic-task" else "parallel")), + "worker_stage_label": str(item.get("worker_stage_label") or ""), + "factory_stage_label": str(item.get("factory_stage_label") or "4. Factory Execution"), + "blueprint_version": str(item.get("blueprint_version") or ""), + "input_manifest": str(item.get("input_manifest") or ""), + "required_inputs": dev_pipeline_template_required_inputs(item), + "workers": [ + { + "id": str(worker.get("id") or ""), + "title": str(worker.get("title") or worker.get("id") or ""), + "file": str(worker.get("file") or ""), + "description": str(worker.get("description") or ""), + "stage": str(worker.get("stage") or ""), + "dependencies": [str(value) for value in worker.get("dependencies", []) if isinstance(value, str)], + } + for worker in item.get("workers", []) + if isinstance(worker, dict) + ], + "factory_steps": [ + { + "id": str(step.get("id") or ""), + "title": str(step.get("title") or step.get("id") or ""), + "file": str(step.get("file") or ""), + "status": title_status(str(step.get("status") or "queued"), "Queued"), + "dependencies": [str(value) for value in step.get("dependencies", []) if isinstance(value, str)], + } + for step in item.get("factory_steps", []) + if isinstance(step, dict) + ], + "validators": [dev_pipeline_validator_config(root, project, item, validator) for validator in item.get("validators", []) if isinstance(validator, dict)], + } + for item in templates + ], + "selected": {"project_id": str(project.get("id") or ""), "template_id": str(template.get("id") or "")}, + "pipeline": { + "id": str(manifest.get("id") or ""), + "run_name": f"{template.get('slug') or template.get('id')}-{project.get('id')}_{str(manifest.get('active_run_id') or '').rsplit('-', 1)[-1]}", + "status": status_label, + "status_detail": str(manifest.get("status_detail") or ""), + "project": str(project.get("label") or project.get("id") or ""), + "surface": str(project.get("surface") or ""), + "template": str(template.get("label") or template.get("id") or ""), + "template_detail": str(template.get("detail") or ""), + "tasks": f"{tasks_completed} / {tasks_total}", + "task_state": "Validated", + "budget": f"${budget_spent:.2f}", + "budget_detail": f"of ${budget_cap:.2f} budget", + "elapsed": str(manifest.get("elapsed") or ""), + "target": str(manifest.get("target") or ""), + "execution_model": execution_model, + "worker_stage_label": worker_stage_label, + "factory_stage_label": factory_stage_label, + "input_count": input_count_label, + "worker_count": worker_count_label, + "integration_count": f"{len(integration_cards)} execution steps" if factory_steps_explicit else f"{len(integration_cards)} integration steps", + "input_cards": [ + { + **deepcopy(item), + "id": str(item.get("id") or ""), + "title": str(item.get("title") or ""), + "file": str(item.get("detail") or item.get("manifest") or ""), + "status": title_status(str(item.get("status") or "missing"), "Missing"), + "required": bool(item.get("required", True)), + } + for item in required_inputs + ], + "workers": worker_cards, + "integration": integration_cards, + "validators": validator_cards, + "evidence": evidence_cards, + "execution_flow": dev_pipeline_execution_flow( + root, + manifest, + project, + template, + required_inputs, + workers, + integration_cards, + validator_cards, + evidence_cards, + event_total, + budget_spent, + budget_cap, + run_id, + ), + "validation": { + "status": title_status(validation_status, "Passed"), + "tier": str(template.get("validation_tier") or validation_receipt.get("tier") or ""), + "receipt": dev_pipeline_relative(root / validation_receipt_rel), + "checks": len(validation_receipt.get("commands") or []), + "validator_manifest": dev_pipeline_relative(root / str(artifacts.get("validator_manifest") or "validation/validator_manifest.json")), + }, + "inspector": { + "selected_worker": str(selected_worker.get("title") or selected_worker_id), + "badge": "W1", + "status": "Completed", + "manifest": worker_manifest, + "manifest_path": dev_pipeline_relative(root / worker_manifest_rel) if worker_manifest_rel else "", + "summary": { + "owned_paths": f"{len(worker_manifest.get('owned_paths') or [])} path", + "read_paths": f"{len(worker_manifest.get('read_paths') or [])} paths", + "dependencies": "None" if not worker_manifest.get("dependencies") else f"{len(worker_manifest.get('dependencies') or [])} dependencies", + "validation_tier": str((worker_manifest.get("validation") or {}).get("tier") or template.get("validation_tier") or ""), + "risk_level": str(template.get("risk") or ""), + }, + }, + }, + "artifacts": [ + dev_pipeline_artifact(root, "pipeline_manifest.json"), + dev_pipeline_artifact(root, str(artifacts.get("workset") or "workset.json")), + dev_pipeline_artifact(root, validation_receipt_rel), + dev_pipeline_artifact(root, evidence_bundle_rel), + dev_pipeline_artifact(root, events_rel), + ], + } def factory_run_list() -> dict[str, Any]: @@ -1530,12 +8450,25 @@ def issue_detail(conn: sqlite3.Connection, issue_id: int) -> dict[str, Any]: ) issue_payload = {**row_dict(row), **test_artifact} issue_payload["validation_state"] = issue_validation_state(issue_id) + custom_fields = issue_custom_fields_for_issue(conn, issue_id) + pipeline_run_id = str(custom_fields.get("Default Pipeline Run") or "").strip() + pipeline_route = None + if pipeline_run_id: + pipeline_route = { + "default": True, + "project_id": str(custom_fields.get("Pipeline Project") or DEFAULT_DEV_PIPELINE_PROJECT_ID), + "template_id": str(custom_fields.get("Pipeline Template") or DEFAULT_DEV_PIPELINE_TEMPLATE_ID), + "run_id": pipeline_run_id, + "status": "routed", + "url": "/dev-pipeline-studio#pipeline-flow", + } return { "issue": issue_payload, "journals": journals, "attachments": attachments, - "custom_fields": issue_custom_fields_for_issue(conn, issue_id), + "custom_fields": custom_fields, "validation_evidences": issue_validation_evidences(conn, issue_id), + **({"pipeline_route": pipeline_route} if pipeline_route else {}), } @@ -1563,6 +8496,43 @@ def artifact_url(path: str) -> str: return f"/api/artifacts?path={quote(path)}" +def latest_demo_video_path() -> str: + demo_root = ROOT_DIR / "workspace" / "runs" / "demo-evidence" + candidates: list[tuple[float, Path]] = [] + for receipt_path in demo_root.glob("*/receipt.json"): + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if str(receipt.get("status") or "").lower() not in {"passed", "ok"}: + continue + if str(receipt.get("recorder") or "").lower() == "synthetic": + continue + artifacts = receipt.get("artifacts") + video_ref = "" + if isinstance(artifacts, dict): + video_ref = str(artifacts.get("video") or "") + video_path = Path(video_ref) if video_ref else receipt_path.parent / "demo.mp4" + if not video_path.is_absolute(): + video_path = ROOT_DIR / video_path + video_path = video_path.resolve() + if ROOT_DIR.resolve() not in video_path.parents and video_path != ROOT_DIR.resolve(): + continue + if not video_path.exists() or not video_path.is_file() or video_path.suffix.lower() not in {".mp4", ".webm", ".mov"}: + fallback_video = receipt_path.parent / "demo.mp4" + if not fallback_video.exists() or not fallback_video.is_file(): + continue + video_path = fallback_video.resolve() + try: + sort_time = max(receipt_path.stat().st_mtime, video_path.stat().st_mtime) + except OSError: + continue + candidates.append((sort_time, video_path)) + if not candidates: + raise AgentWorkAppError("No recorded demo evidence video found.") + return str(max(candidates, key=lambda item: item[0])[1].relative_to(ROOT_DIR)) + + TEST_ARTIFACT_PATTERNS = ( "browser workflow demo", "browser workflow", @@ -1975,7 +8945,8 @@ def create_local_issue(conn: sqlite3.Connection, payload: dict[str, Any]) -> dic "insert into journals(issue_id, author, created_on, notes, new_status, source) values (?, ?, ?, ?, ?, 'local')", (issue_id, assignee, now, "Created in Cento Taskstream.", status), ) - return issue_detail(conn, issue_id) + detail = issue_detail(conn, issue_id) + return detail def update_local_issue(conn: sqlite3.Connection, issue_id: int, payload: dict[str, Any]) -> dict[str, Any]: @@ -2083,9 +9054,851 @@ def add_local_attachment(conn: sqlite3.Connection, issue_id: int, payload: dict[ return issue_detail(conn, issue_id) +PATCH_SWARM_PRODUCT_WORKTREE_ROOT = ROOT_DIR / "workspace" / "runs" / "patch-swarm-product-worktrees" +PATCH_SWARM_PROTECTED_PREFIXES = (".git/", ".ssh/", ".gnupg/", ".oci/") +PATCH_SWARM_PROTECTED_NAMES = {".env", ".env.mcp", "secrets.env", "id_rsa", "id_ed25519"} +PATCH_SWARM_PROTECTED_SUFFIXES = (".pem", ".key", ".p12", ".pfx") + + +def patch_swarm_engine(): + import parallel_delivery as patch_swarm # local import avoids a module-import cycle + + return patch_swarm + + +def patch_swarm_console_tool(): + import parallel_delivery_patch_swarm_console as console_tool + + return console_tool + + +def patch_swarm_git(repo: Path, *args: str, timeout: int = 20) -> subprocess.CompletedProcess[str]: + return subprocess.run(["git", *args], cwd=repo, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, check=False) + + +def patch_swarm_repo_search_roots() -> list[Path]: + raw = os.environ.get("CENTO_PATCH_SWARM_REPO_ROOTS", "") + roots = [Path(item).expanduser() for item in raw.split(os.pathsep) if item.strip()] if raw else [ROOT_DIR, Path.home() / "projects"] + unique: list[Path] = [] + seen: set[Path] = set() + for root in roots: + resolved = root.resolve() if root.exists() else root + if resolved not in seen: + seen.add(resolved) + unique.append(resolved) + return unique + + +def patch_swarm_protected_path(path: str) -> bool: + normalized = str(path or "").strip().lstrip("/") + name = Path(normalized).name + lowered = normalized.lower() + return ( + name in PATCH_SWARM_PROTECTED_NAMES + or lowered.startswith(PATCH_SWARM_PROTECTED_PREFIXES) + or lowered.endswith(PATCH_SWARM_PROTECTED_SUFFIXES) + or "/.env" in lowered + or "secret" in lowered + ) + + +def patch_swarm_status_path(line: str) -> str: + raw = line[3:] if len(line) > 3 else line + if " -> " in raw: + raw = raw.rsplit(" -> ", 1)[-1] + return raw.strip() + + +def patch_swarm_repo_state(repo: Path) -> dict[str, Any]: + repo = repo.expanduser().resolve() + top = patch_swarm_git(repo, "rev-parse", "--show-toplevel") + if top.returncode != 0: + raise AgentWorkAppError(f"not a git repository: {repo}") + root = Path(top.stdout.strip()).resolve() + branch_result = patch_swarm_git(root, "rev-parse", "--abbrev-ref", "HEAD") + sha_result = patch_swarm_git(root, "rev-parse", "--short", "HEAD") + status_result = patch_swarm_git(root, "status", "--porcelain=v1") + status_lines = [line for line in status_result.stdout.splitlines() if line.strip()] + dirty_paths = [patch_swarm_status_path(line) for line in status_lines] + protected_dirty = [path for path in dirty_paths if patch_swarm_protected_path(path)] + safety_label = "clean_startable" + if protected_dirty: + safety_label = "blocked_protected_dirty" + elif dirty_paths: + safety_label = "startable_unprotected_dirty" + return { + "path": str(root), + "name": root.name, + "branch": branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown", + "head": sha_result.stdout.strip() if sha_result.returncode == 0 else "", + "dirty": bool(status_lines), + "dirty_count": len(status_lines), + "dirty_paths": dirty_paths[:50], + "protected_dirty": protected_dirty[:50], + "protected_dirty_count": len(protected_dirty), + "can_start": not protected_dirty, + "can_apply_without_override": not status_lines, + "safety_label": safety_label, + "start_disabled_reason": "protected dirty paths must be cleared" if protected_dirty else "", + } + + +def patch_swarm_discover_repos() -> dict[str, Any]: + repos: dict[str, dict[str, Any]] = {} + for root in patch_swarm_repo_search_roots(): + if not root.exists() or not root.is_dir(): + continue + candidates = [root] + try: + candidates.extend(path for path in root.iterdir() if path.is_dir() and not path.name.startswith(".")) + except OSError: + continue + for candidate in candidates: + if not (candidate / ".git").exists(): + continue + try: + state = patch_swarm_repo_state(candidate) + except (AgentWorkAppError, OSError, subprocess.SubprocessError): + continue + repos[state["path"]] = state + ordered = sorted(repos.values(), key=lambda item: (item["name"].lower(), item["path"])) + return { + "schema_version": "cento.patch_swarm.repo_index.v1", + "repos": ordered, + "search_roots": [str(path) for path in patch_swarm_repo_search_roots()], + "protected_policy": { + "names": sorted(PATCH_SWARM_PROTECTED_NAMES), + "suffixes": list(PATCH_SWARM_PROTECTED_SUFFIXES), + "prefixes": list(PATCH_SWARM_PROTECTED_PREFIXES), + }, + } + + +def patch_swarm_detect_test_commands(repo: Path) -> list[str]: + commands: list[str] = [] + if (repo / "package.json").exists(): + commands.append("npm test") + if (repo / "pyproject.toml").exists() or (repo / "pytest.ini").exists() or (repo / "tests").exists(): + commands.append("python3 -m pytest") + if (repo / "go.mod").exists(): + commands.append("go test ./...") + if (repo / "Cargo.toml").exists(): + commands.append("cargo test") + if (repo / "Makefile").exists(): + commands.append("make test") + return commands + + +def patch_swarm_run_path(run_id: str) -> Path: + return patch_swarm_engine().resolve_patch_swarm_run_dir(run_id) + + +def patch_swarm_console_run_dir(run_id: str = "", raw_run_dir: str = "") -> Path: + tool = patch_swarm_console_tool() + if raw_run_dir: + run_dir = tool.normalize_run_dir(Path(raw_run_dir)) + elif run_id: + run_dir = patch_swarm_run_path(run_id) + else: + run_dir = tool.normalize_run_dir(tool.RUNS_ROOT) + workspace_root = (ROOT_DIR / "workspace" / "runs").resolve() + if workspace_root not in run_dir.parents and run_dir != workspace_root: + raise AgentWorkAppError("Patch Swarm console run_dir must be under workspace/runs.") + return run_dir + + +def patch_swarm_console_render(run_id: str = "", raw_run_dir: str = "") -> tuple[dict[str, Any], Path]: + tool = patch_swarm_console_tool() + run_dir = patch_swarm_console_run_dir(run_id=run_id, raw_run_dir=raw_run_dir) + console_data, metadata = tool.render_console(run_dir, write_html=True) + html_path = (ROOT_DIR / metadata["start_here"]).resolve() + payload = tool.console_data_to_dict(console_data) + payload["artifacts"] = metadata + return payload, html_path + + +def patch_swarm_append_product_event(run_dir: Path, event: str, payload: dict[str, Any]) -> None: + row = {"written_at": datetime.now(timezone.utc).isoformat(), "event": event, **payload} + path = run_dir / "product_events.ndjson" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, sort_keys=True) + "\n") + + +def patch_swarm_product_history(run_dir: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for path in (run_dir / "events.ndjson", run_dir / "product_events.ndjson"): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + continue + for line in lines[-80:]: + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict): + rows.append(row) + return sorted(rows, key=lambda item: str(item.get("written_at") or ""))[-120:] + + +def patch_swarm_product_run_kind(manifest: dict[str, Any], metadata: dict[str, Any]) -> str: + if metadata.get("schema_version") == "cento.patch_swarm.product_metadata.v1" or manifest.get("product_metadata"): + return "product" + return "engine" + + +def patch_swarm_repo_dirty_file_fingerprints(repo: Path, dirty_paths: list[str]) -> dict[str, Any]: + fingerprints: dict[str, Any] = {} + for dirty_path in dirty_paths[:100]: + clean = str(dirty_path or "").strip().lstrip("/") + if not clean: + continue + path = (repo / clean).resolve() + if repo not in path.parents and path != repo: + fingerprints[clean] = {"state": "outside_repo"} + continue + try: + if path.is_file(): + stat = path.stat() + if stat.st_size <= 2_000_000: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + fingerprints[clean] = {"state": "file", "size": stat.st_size, "sha256": digest} + else: + fingerprints[clean] = {"state": "large_file", "size": stat.st_size} + elif path.exists(): + fingerprints[clean] = {"state": "non_file"} + else: + fingerprints[clean] = {"state": "missing"} + except OSError as exc: + fingerprints[clean] = {"state": "error", "error": str(exc)} + return fingerprints + + +def patch_swarm_repo_snapshot(repo: Path) -> dict[str, Any]: + state = patch_swarm_repo_state(repo) + root = Path(state["path"]).resolve() + head_result = patch_swarm_git(root, "rev-parse", "HEAD") + tree_result = patch_swarm_git(root, "rev-parse", "HEAD^{tree}") + status_result = patch_swarm_git(root, "status", "--porcelain=v1") + status_lines = [line for line in status_result.stdout.splitlines() if line.strip()] + dirty_paths = [patch_swarm_status_path(line) for line in status_lines] + snapshot = { + "schema_version": "cento.patch_swarm.repo_snapshot.v1", + "path": str(root), + "branch": state.get("branch", ""), + "head": head_result.stdout.strip() if head_result.returncode == 0 else state.get("head", ""), + "head_tree": tree_result.stdout.strip() if tree_result.returncode == 0 else "", + "status_porcelain": status_lines, + "dirty_paths": dirty_paths, + "dirty_file_fingerprints": patch_swarm_repo_dirty_file_fingerprints(root, dirty_paths), + "captured_at": datetime.now(timezone.utc).isoformat(), + } + comparable = { + "branch": snapshot["branch"], + "head": snapshot["head"], + "head_tree": snapshot["head_tree"], + "status_porcelain": snapshot["status_porcelain"], + "dirty_file_fingerprints": snapshot["dirty_file_fingerprints"], + } + snapshot["fingerprint"] = hashlib.sha256(json.dumps(comparable, sort_keys=True, default=str).encode("utf-8")).hexdigest() + return snapshot + + +def patch_swarm_write_no_mutation_receipt( + run_dir: Path, + phase: str, + before: dict[str, Any], + after: dict[str, Any], + *, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + engine = patch_swarm_engine() + compared_fields = ["branch", "head", "head_tree", "status_porcelain", "dirty_file_fingerprints"] + changed_fields = [field for field in compared_fields if before.get(field) != after.get(field)] + status = "passed" if not changed_fields and before.get("fingerprint") == after.get("fingerprint") else "failed" + receipt = { + "schema_version": "cento.patch_swarm.no_selected_repo_mutation.v1", + "run_id": run_dir.name, + "phase": phase, + "status": status, + "selected_repo": before.get("path") or after.get("path") or "", + "compared_fields": compared_fields, + "changed_fields": changed_fields, + "before": before, + "after": after, + "written_at": datetime.now(timezone.utc).isoformat(), + **(extra or {}), + } + receipt_path = run_dir / f"product_no_mutation_{phase}.json" + engine.write_json(receipt_path, receipt) + aggregate = engine.read_json(run_dir / "product_no_mutation_checks.json") + checks = [item for item in aggregate.get("checks", []) if isinstance(item, dict) and str(item.get("phase") or "") != phase] + checks.append( + { + "phase": phase, + "status": status, + "receipt": engine.rel(receipt_path), + "changed_fields": changed_fields, + "written_at": receipt["written_at"], + } + ) + engine.write_json( + run_dir / "product_no_mutation_checks.json", + { + "schema_version": "cento.patch_swarm.no_selected_repo_mutation_index.v1", + "run_id": run_dir.name, + "status": "passed" if checks and all(item.get("status") == "passed" for item in checks) else "failed", + "checks": checks, + "written_at": datetime.now(timezone.utc).isoformat(), + }, + ) + return receipt + + +def patch_swarm_product_artifacts(run_dir: Path, ui_state: dict[str, Any]) -> dict[str, Any]: + engine = patch_swarm_engine() + artifacts = dict(ui_state.get("artifacts") if isinstance(ui_state.get("artifacts"), dict) else {}) + optional = { + "product_metadata": run_dir / "product_metadata.json", + "create_receipt": run_dir / "product_run_create_receipt.json", + "approval": run_dir / "supervised_approval.json", + "candidate_decisions": run_dir / "candidate_decisions.json", + "apply_receipt": run_dir / "product_safe_integrator_apply.json", + "no_mutation": run_dir / "product_no_mutation_checks.json", + "no_mutation_create": run_dir / "product_no_mutation_create.json", + "no_mutation_apply": run_dir / "product_no_mutation_apply.json", + "validation_summary": run_dir / "validation_summary.json", + } + for key, path in optional.items(): + if path.exists(): + artifacts[key] = engine.rel(path) + return artifacts + + +def patch_swarm_product_candidate_rows(run_dir: Path) -> list[dict[str, Any]]: + index = patch_swarm_engine().read_json(run_dir / "candidate_index.json") + return [dict(item) for item in index.get("candidates", []) if isinstance(item, dict)] + + +def patch_swarm_product_action_gates( + run_dir: Path, + *, + run_kind: str | None = None, + candidates: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + engine = patch_swarm_engine() + manifest = engine.read_json(run_dir / "patch_swarm_manifest.json") + metadata = engine.read_json(run_dir / "product_metadata.json") + kind = run_kind or patch_swarm_product_run_kind(manifest, metadata) + base = { + "schema_version": "cento.patch_swarm.action_gates.v1", + "run_id": run_dir.name, + "can_approve": False, + "can_apply": False, + "can_reject": False, + "approve_disabled_reason": "", + "apply_disabled_reason": "", + "reject_disabled_reason": "", + } + if kind != "product": + return { + **base, + "approve_disabled_reason": "engine-only run", + "apply_disabled_reason": "engine-only run", + "reject_disabled_reason": "engine-only run", + } + candidate_rows = candidates if candidates is not None else patch_swarm_product_candidate_rows(run_dir) + by_id = {str(item.get("id") or ""): item for item in candidate_rows} + integration = engine.read_json(run_dir / "integration_execution" / "integration_execution.json") + validation = engine.read_json(run_dir / "validation_summary.json") + approval = engine.read_json(run_dir / "supervised_approval.json") + apply_receipt = engine.read_json(run_dir / "product_safe_integrator_apply.json") + selected_ids = [str(item) for item in integration.get("selected_candidates", []) if str(item)] + approved_ids = [str(item) for item in approval.get("selected_candidate_ids", []) if str(item)] + already_approved = approval.get("status") == "approved" + already_applied = apply_receipt.get("status") == "applied" + + def all_validated(ids: list[str]) -> bool: + return bool(ids) and all(str(by_id.get(candidate_id, {}).get("status") or "") == "validated" for candidate_id in ids) + + approve_reason = "" + if already_applied: + approve_reason = "already applied" + elif already_approved: + approve_reason = "already approved" + elif validation.get("status") != "passed": + approve_reason = "validation not passed" + elif not selected_ids: + approve_reason = "no selected candidates" + elif not all_validated(selected_ids): + approve_reason = "selected candidates not validated" + + apply_reason = "" + if already_applied: + apply_reason = "already applied" + elif not already_approved: + apply_reason = "approval required" + elif not all_validated(approved_ids): + apply_reason = "approved candidates not validated" + + reject_reason = "" + if already_applied: + reject_reason = "already applied" + elif already_approved: + reject_reason = "already approved" + elif not candidate_rows: + reject_reason = "no candidates" + + return { + **base, + "can_approve": approve_reason == "", + "can_apply": apply_reason == "", + "can_reject": reject_reason == "", + "approve_disabled_reason": approve_reason, + "apply_disabled_reason": apply_reason, + "reject_disabled_reason": reject_reason, + } + + +def patch_swarm_product_owned_path(run_id: str, execution_id: str) -> str: + clean_run = re.sub(r"[^A-Za-z0-9_.-]+", "-", run_id).strip("-") or "run" + clean_execution = re.sub(r"[^A-Za-z0-9_.-]+", "-", execution_id).strip("-") or "execution" + return f"patch-swarm-candidates/{clean_run}/{clean_execution}.md" + + +def patch_swarm_retarget_run_to_repo(run_dir: Path, repo: Path, task_brief: str) -> None: + engine = patch_swarm_engine() + manifest = engine.read_json(run_dir / "patch_swarm_manifest.json") + proreq = engine.read_json(run_dir / "proreq_execution_manifest.json") + providers = engine.patch_swarm_provider_list(manifest.get("providers") if isinstance(manifest.get("providers"), list) else "") + executions = [item for item in proreq.get("executions", []) if isinstance(item, dict)] + for execution in executions: + execution_id = str(execution.get("id") or "execution") + owned_path = patch_swarm_product_owned_path(run_dir.name, execution_id) + execution["owned_paths"] = [owned_path] + execution_dir = run_dir / "proreq_executions" / execution_id + request_path = execution_dir / "proreq_request.json" + request = engine.read_json(request_path) + if request: + request["owned_paths"] = [owned_path] + request["selected_repo"] = str(repo) + request["task_brief"] = task_brief + engine.write_json(request_path, request) + (execution_dir / "prompt.md").write_text( + engine.patch_swarm_prompt_text(task_brief, execution, providers, int(execution.get("candidate_target") or 1)), + encoding="utf-8", + ) + proreq["executions"] = executions + proreq["selected_repo"] = str(repo) + engine.write_json(run_dir / "proreq_execution_manifest.json", proreq) + + +def patch_swarm_write_product_metadata(run_dir: Path, metadata: dict[str, Any]) -> dict[str, Any]: + engine = patch_swarm_engine() + metadata = { + "schema_version": "cento.patch_swarm.product_metadata.v1", + "run_id": run_dir.name, + "written_at": datetime.now(timezone.utc).isoformat(), + **metadata, + } + engine.write_json(run_dir / "product_metadata.json", metadata) + manifest = engine.read_json(run_dir / "patch_swarm_manifest.json") + manifest["product_metadata"] = engine.rel(run_dir / "product_metadata.json") + manifest["selected_repo"] = metadata.get("selected_repo", {}) + manifest["task_brief"] = metadata.get("task_brief", "") + manifest["validation_profile"] = metadata.get("validation_profile", "deterministic") + manifest["provider_preset"] = metadata.get("provider_preset", "balanced") + engine.write_json(run_dir / "patch_swarm_manifest.json", manifest) + engine.patch_swarm_write_ui_state(run_dir) + return metadata + + +def patch_swarm_product_candidates(run_dir: Path) -> list[dict[str, Any]]: + engine = patch_swarm_engine() + index = engine.read_json(run_dir / "candidate_index.json") + candidates = [dict(item) for item in index.get("candidates", []) if isinstance(item, dict)] + decisions = engine.read_json(run_dir / "candidate_decisions.json") + rejected = {str(item.get("candidate_id") or ""): item for item in decisions.get("rejected", []) if isinstance(item, dict)} + for candidate in candidates: + candidate_id = str(candidate.get("id") or "") + patch_file = str((candidate.get("patch") or {}).get("patch_file") or "") + diff_preview = "" + if patch_file: + patch_path = engine.resolve_cento_path(patch_file) + try: + diff_preview = patch_path.read_text(encoding="utf-8", errors="ignore")[:8000] + except OSError: + diff_preview = "" + candidate["diff_preview"] = diff_preview + candidate["decision"] = "rejected" if candidate_id in rejected else "" + candidate["confidence"] = max(0, min(100, int(round(float(candidate.get("score") or 0))))) + return candidates + + +def patch_swarm_product_run_detail(run_id: str, *, include_candidates: bool = True) -> dict[str, Any]: + engine = patch_swarm_engine() + run_dir = patch_swarm_run_path(run_id) + manifest = engine.read_json(run_dir / "patch_swarm_manifest.json") + if not manifest: + raise AgentWorkAppError(f"Patch Swarm run not found: {run_id}") + ui_state = engine.read_json(run_dir / "ui_state.json") + receipt = engine.read_json(run_dir / "patch_swarm_receipt.json") + integration = engine.read_json(run_dir / "integration_execution" / "integration_execution.json") + validation = engine.read_json(run_dir / "validation_summary.json") + approval = engine.read_json(run_dir / "supervised_approval.json") + apply_receipt = engine.read_json(run_dir / "product_safe_integrator_apply.json") + factory_promotion = engine.read_json(run_dir / "factory_promotion.json") + metadata = engine.read_json(run_dir / "product_metadata.json") + no_mutation = engine.read_json(run_dir / "product_no_mutation_checks.json") + selected_repo = metadata.get("selected_repo") if isinstance(metadata.get("selected_repo"), dict) else manifest.get("selected_repo", {}) + run_kind = patch_swarm_product_run_kind(manifest, metadata) + candidates = patch_swarm_product_candidates(run_dir) if include_candidates else [] + groups: dict[str, list[dict[str, Any]]] = {} + for candidate in candidates: + groups.setdefault(str(candidate.get("execution_id") or "unknown"), []).append(candidate) + artifacts = patch_swarm_product_artifacts(run_dir, ui_state) + action_gates = patch_swarm_product_action_gates( + run_dir, + run_kind=run_kind, + candidates=candidates if include_candidates else None, + ) + run = { + "run_id": run_dir.name, + "run_kind": run_kind, + "run_dir": engine.rel(run_dir), + "status": ui_state.get("status") or validation.get("status") or integration.get("status") or receipt.get("status") or manifest.get("status", "unknown"), + "task_brief": metadata.get("task_brief") or manifest.get("objective", ""), + "selected_repo": selected_repo, + "candidate_target": manifest.get("candidate_target", 0), + "candidate_count": receipt.get("candidate_count", 0), + "selected_count": integration.get("selected_count", 0), + "estimated_cost_usd": receipt.get("estimated_cost_usd", 0.0), + "providers": manifest.get("providers", []), + "validation": validation.get("status", "unknown"), + "safe_integrator_status": (engine.read_json(run_dir / "safe_integrator_handoff.json")).get("status", ""), + "approval_status": approval.get("status", "not_approved"), + "apply_status": apply_receipt.get("status") or factory_promotion.get("status") or "not_applied", + "no_mutation_status": no_mutation.get("status", ""), + "created_at": manifest.get("created_at", ""), + "updated_at": manifest.get("updated_at", ""), + "artifacts": artifacts, + "action_gates": action_gates, + } + return { + "schema_version": "cento.patch_swarm.product_run_detail.v1", + "run": run, + "run_kind": run_kind, + "action_gates": action_gates, + "ui_state": ui_state, + "metadata": metadata, + "receipt": receipt, + "integration": integration, + "validation_summary": validation, + "approval": approval, + "apply_receipt": apply_receipt, + "factory_promotion": factory_promotion, + "no_mutation": no_mutation, + "candidates": candidates, + "candidate_groups": [{"execution_id": key, "candidates": value} for key, value in groups.items()], + "history": patch_swarm_product_history(run_dir), + } + + +def patch_swarm_product_run_list() -> dict[str, Any]: + engine = patch_swarm_engine() + root = engine.PATCH_SWARM_RUNS_ROOT + runs: list[dict[str, Any]] = [] + if root.exists(): + for run_dir in sorted(root.iterdir(), key=lambda path: path.stat().st_mtime, reverse=True): + if not run_dir.is_dir() or not (run_dir / "patch_swarm_manifest.json").exists(): + continue + runs.append(patch_swarm_product_run_detail(run_dir.name, include_candidates=False)["run"]) + return { + "schema_version": "cento.patch_swarm.run_index.v1", + "runs": runs[:50], + "summary": { + "total": len(runs), + "approved": sum(1 for item in runs if item.get("approval_status") == "approved"), + "applied": sum(1 for item in runs if item.get("apply_status") == "applied"), + }, + } + + +def patch_swarm_product_create_run(payload: dict[str, Any]) -> dict[str, Any]: + engine = patch_swarm_engine() + repo_path = str(payload.get("repo_path") or payload.get("repo") or "").strip() + if not repo_path: + raise AgentWorkAppError("repo_path is required") + repo_state = patch_swarm_repo_state(Path(repo_path)) + if repo_state.get("protected_dirty"): + raise AgentWorkAppError("selected repo has protected dirty paths: " + ", ".join(repo_state["protected_dirty"][:5])) + repo = Path(repo_state["path"]) + before_snapshot = patch_swarm_repo_snapshot(repo) + task_brief = str(payload.get("task_brief") or payload.get("objective") or "").strip() + if not task_brief: + raise AgentWorkAppError("task_brief is required") + run_id = str(payload.get("run_id") or f"patch-swarm-product-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}") + run_dir = engine.resolve_patch_swarm_run_dir(run_id, create=True) + candidate_target = max(10, int(payload.get("candidate_target") or 30)) + max_parallel_agents = max(1, int(payload.get("max_parallel_agents") or 3)) + providers = engine.patch_swarm_provider_list(payload.get("providers") or "codex-exec,claude-code,api-openai") + mode = str(payload.get("mode") or "fixture").lower() + live = mode in {"live", "real"} + if live: + raise AgentWorkAppError("Patch Swarm product runs are fixture-only in this release candidate; live provider dispatch is deferred") + engine.build_patch_swarm_plan(run_dir, objective=task_brief, candidate_target=candidate_target, max_parallel_agents=max_parallel_agents, providers=providers, live=live) + patch_swarm_retarget_run_to_repo(run_dir, repo, task_brief) + patch_swarm_write_product_metadata( + run_dir, + { + "selected_repo": repo_state, + "task_brief": task_brief, + "validation_profile": str(payload.get("validation_profile") or "deterministic"), + "provider_preset": str(payload.get("provider_preset") or "balanced"), + "test_commands": patch_swarm_detect_test_commands(repo), + "ui_state": {"active_candidate_id": "", "compare_candidate_ids": [], "filters": {}}, + "apply_policy": "supervised-safe-integrator-worktree", + }, + ) + receipt = engine.execute_patch_swarm( + run_dir, + fixture=not live, + budget_cap_usd=payload.get("budget_cap_usd"), + max_budget_usd=payload.get("max_budget_usd"), + api_sandbox_candidates=int(payload.get("api_sandbox_candidates") or 1), + ) + if receipt.get("status") == "candidates_generated": + engine.integrate_patch_swarm(run_dir) + engine.validate_patch_swarm_run(run_dir) + engine.patch_swarm_write_ui_state(run_dir) + after_snapshot = patch_swarm_repo_snapshot(repo) + no_mutation = patch_swarm_write_no_mutation_receipt( + run_dir, + "create", + before_snapshot, + after_snapshot, + extra={ + "operation": "fixture_run_creation", + "mutation_boundary": "run artifacts only", + }, + ) + create_receipt = { + "schema_version": "cento.patch_swarm.product_create_receipt.v1", + "run_id": run_dir.name, + "status": "created" if no_mutation.get("status") == "passed" else "blocked", + "mode": mode, + "selected_repo": repo_state, + "candidate_count": receipt.get("candidate_count", 0), + "validation": engine.rel(run_dir / "validation_summary.json") if (run_dir / "validation_summary.json").exists() else "", + "no_mutation_receipt": engine.rel(run_dir / "product_no_mutation_create.json"), + "written_at": datetime.now(timezone.utc).isoformat(), + } + engine.write_json(run_dir / "product_run_create_receipt.json", create_receipt) + engine.patch_swarm_write_ui_state(run_dir) + patch_swarm_append_product_event(run_dir, "product_run_created", {"repo": repo_state["path"], "candidate_target": candidate_target, "mode": mode}) + if no_mutation.get("status") != "passed": + raise AgentWorkAppError("selected repo changed during fixture run creation") + return patch_swarm_product_run_detail(run_dir.name) + + +def patch_swarm_product_selected_candidates(run_dir: Path, candidate_ids: list[str] | None = None) -> list[dict[str, Any]]: + candidates = patch_swarm_product_candidates(run_dir) + by_id = {str(item.get("id") or ""): item for item in candidates} + selected_ids = [str(item) for item in candidate_ids or [] if str(item)] + if not selected_ids: + integration = patch_swarm_engine().read_json(run_dir / "integration_execution" / "integration_execution.json") + selected_ids = [str(item) for item in integration.get("selected_candidates", []) if str(item)] + selected = [by_id[item] for item in selected_ids if item in by_id] + if not selected: + raise AgentWorkAppError("no selected candidates are available for approval") + invalid = [str(item.get("id") or "") for item in selected if str(item.get("status") or "") != "validated"] + if invalid: + raise AgentWorkAppError("approval requires validated candidates: " + ", ".join(invalid)) + return selected + + +def patch_swarm_product_approve(run_id: str, payload: dict[str, Any]) -> dict[str, Any]: + engine = patch_swarm_engine() + run_dir = patch_swarm_run_path(run_id) + gates = patch_swarm_product_action_gates(run_dir) + if not gates.get("can_approve"): + raise AgentWorkAppError(str(gates.get("approve_disabled_reason") or "approval is disabled")) + selected = patch_swarm_product_selected_candidates(run_dir, payload.get("candidate_ids") if isinstance(payload.get("candidate_ids"), list) else None) + approval = { + "schema_version": "cento.patch_swarm.supervised_approval.v1", + "run_id": run_dir.name, + "status": "approved", + "approved_at": datetime.now(timezone.utc).isoformat(), + "approved_by": str(payload.get("approved_by") or "local-operator"), + "notes": str(payload.get("notes") or ""), + "selected_candidate_ids": [str(item.get("id") or "") for item in selected], + "selected_count": len(selected), + "apply_policy": "Factory/Safe Integrator worktree only; no direct selected-repo mutation", + } + engine.write_json(run_dir / "supervised_approval.json", approval) + engine.patch_swarm_write_ui_state(run_dir) + patch_swarm_append_product_event(run_dir, "product_run_approved", {"selected_count": len(selected)}) + return patch_swarm_product_run_detail(run_dir.name) + + +def patch_swarm_product_reject(run_id: str, payload: dict[str, Any]) -> dict[str, Any]: + engine = patch_swarm_engine() + run_dir = patch_swarm_run_path(run_id) + gates = patch_swarm_product_action_gates(run_dir) + if not gates.get("can_reject"): + raise AgentWorkAppError(str(gates.get("reject_disabled_reason") or "reject is disabled")) + candidate_ids = [str(item) for item in payload.get("candidate_ids", []) if str(item)] if isinstance(payload.get("candidate_ids"), list) else [] + if not candidate_ids: + raise AgentWorkAppError("candidate_ids is required") + decisions = engine.read_json(run_dir / "candidate_decisions.json") + existing = [item for item in decisions.get("rejected", []) if isinstance(item, dict) and str(item.get("candidate_id") or "") not in set(candidate_ids)] + for candidate_id in candidate_ids: + existing.append( + { + "candidate_id": candidate_id, + "decision": "rejected", + "reason": str(payload.get("reason") or "Rejected in Patch Swarm review."), + "decided_at": datetime.now(timezone.utc).isoformat(), + } + ) + engine.write_json( + run_dir / "candidate_decisions.json", + { + "schema_version": "cento.patch_swarm.candidate_decisions.v1", + "run_id": run_dir.name, + "rejected": existing, + "written_at": datetime.now(timezone.utc).isoformat(), + }, + ) + engine.patch_swarm_write_ui_state(run_dir) + patch_swarm_append_product_event(run_dir, "product_candidates_rejected", {"candidate_ids": candidate_ids}) + return patch_swarm_product_run_detail(run_dir.name) + + +def patch_swarm_remove_product_worktree(path: Path, repo: Path) -> None: + if not path.exists(): + return + root = PATCH_SWARM_PRODUCT_WORKTREE_ROOT.resolve() + resolved = path.resolve() + if root not in resolved.parents and resolved != root: + raise AgentWorkAppError(f"refusing to remove non-product worktree: {path}") + patch_swarm_git(repo, "worktree", "remove", "--force", str(resolved), timeout=60) + + +def patch_swarm_product_external_apply(run_dir: Path, selected: list[dict[str, Any]], payload: dict[str, Any]) -> dict[str, Any]: + engine = patch_swarm_engine() + metadata = engine.read_json(run_dir / "product_metadata.json") + repo_info = metadata.get("selected_repo") if isinstance(metadata.get("selected_repo"), dict) else {} + repo = Path(str(repo_info.get("path") or "")).expanduser().resolve() + if not repo.exists(): + raise AgentWorkAppError("selected repo is unavailable") + branch = str(payload.get("branch") or f"patch-swarm/{run_dir.name}") + worktree = Path(str(payload.get("worktree") or PATCH_SWARM_PRODUCT_WORKTREE_ROOT / run_dir.name)).expanduser() + if not worktree.is_absolute(): + worktree = ROOT_DIR / worktree + product_root = PATCH_SWARM_PRODUCT_WORKTREE_ROOT.resolve() + resolved_worktree = worktree.resolve() + if product_root not in resolved_worktree.parents and resolved_worktree != product_root: + raise AgentWorkAppError(f"refusing non-product Patch Swarm worktree: {worktree}") + patch_swarm_remove_product_worktree(worktree, repo) + worktree.parent.mkdir(parents=True, exist_ok=True) + add_result = patch_swarm_git(repo, "worktree", "add", "-f", "-B", branch, str(worktree), "HEAD", timeout=120) + applied: list[dict[str, Any]] = [] + rejected: list[dict[str, Any]] = [] + if add_result.returncode == 0: + limit = int(payload.get("limit") or 0) + candidates = selected[:limit] if limit > 0 else selected + for candidate in candidates: + patch_file = str((candidate.get("patch") or {}).get("patch_file") or "") + patch_path = engine.resolve_cento_path(patch_file) + check = patch_swarm_git(worktree, "apply", "--check", str(patch_path), timeout=60) + if check.returncode != 0: + rejected.append({"candidate_id": candidate.get("id"), "reason": "git apply check failed", "stderr_tail": check.stderr[-1000:]}) + continue + apply_result = patch_swarm_git(worktree, "apply", str(patch_path), timeout=60) + if apply_result.returncode == 0: + applied.append({"candidate_id": candidate.get("id"), "patch_file": patch_file, "touched_paths": candidate.get("touched_paths", [])}) + else: + rejected.append({"candidate_id": candidate.get("id"), "reason": "git apply failed", "stderr_tail": apply_result.stderr[-1000:]}) + receipt = { + "schema_version": "cento.patch_swarm.external_safe_integrator_apply.v1", + "run_id": run_dir.name, + "status": "applied" if add_result.returncode == 0 and applied and not rejected else "apply_blocked", + "apply_scope": "product_worktree_only", + "selected_repo": str(repo), + "branch": branch, + "worktree": str(worktree), + "worktree_add": { + "exit_code": add_result.returncode, + "stdout_tail": add_result.stdout[-1000:], + "stderr_tail": add_result.stderr[-1000:], + }, + "applied": applied, + "rejected": rejected, + "applied_count": len(applied), + "rejected_count": len(rejected), + "written_at": datetime.now(timezone.utc).isoformat(), + } + engine.write_json(run_dir / "product_safe_integrator_apply.json", receipt) + engine.patch_swarm_write_ui_state(run_dir) + patch_swarm_append_product_event(run_dir, "product_safe_integrator_apply", {"status": receipt["status"], "applied_count": len(applied), "rejected_count": len(rejected)}) + return receipt + + +def patch_swarm_product_apply(run_id: str, payload: dict[str, Any]) -> dict[str, Any]: + engine = patch_swarm_engine() + run_dir = patch_swarm_run_path(run_id) + gates = patch_swarm_product_action_gates(run_dir) + if not gates.get("can_apply"): + raise AgentWorkAppError(str(gates.get("apply_disabled_reason") or "apply is disabled")) + approval = engine.read_json(run_dir / "supervised_approval.json") + if approval.get("status") != "approved": + raise AgentWorkAppError("apply requires supervised approval") + selected_ids = [str(item) for item in approval.get("selected_candidate_ids", []) if str(item)] + selected = patch_swarm_product_selected_candidates(run_dir, selected_ids) + repo_root = engine.patch_swarm_selected_repo_root(run_dir).resolve() + before_snapshot = patch_swarm_repo_snapshot(repo_root) + receipt = patch_swarm_product_external_apply(run_dir, selected, payload) + after_snapshot = patch_swarm_repo_snapshot(repo_root) + no_mutation = patch_swarm_write_no_mutation_receipt( + run_dir, + "apply", + before_snapshot, + after_snapshot, + extra={ + "operation": "supervised_product_worktree_apply", + "mutation_boundary": "Patch Swarm product worktree only", + "worktree": str(receipt.get("worktree") or ""), + }, + ) + receipt["no_mutation_receipt"] = engine.rel(run_dir / "product_no_mutation_apply.json") + receipt["selected_repo_unchanged"] = no_mutation.get("status") == "passed" + engine.write_json(run_dir / "product_safe_integrator_apply.json", receipt) + engine.patch_swarm_write_ui_state(run_dir) + if no_mutation.get("status") != "passed": + raise AgentWorkAppError("selected repo changed during Patch Swarm worktree apply") + return patch_swarm_product_run_detail(run_dir.name) + + def safe_static_path(raw_path: str) -> Path: route = raw_path.split("?", 1)[0].split("#", 1)[0] - if route in ("", "/") or route in {"/review", "/cluster", "/consulting", "/factory", "/docs", "/research-center"} or route.startswith("/issues/"): + app_routes = { + "/", + "/review", + "/cluster", + "/consulting", + "/factory", + "/patch-swarm", + "/docs", + "/research-center", + "/software-delivery-hub", + "/dev-pipeline-studio", + "/codebase-intelligence", + "/issues", + "/issues/new", + } + if route in ("",) or route in app_routes or route.startswith("/issues/") or route.startswith("/patch-swarm/runs/"): route = "/index.html" path = (TEMPLATE_DIR / route.lstrip("/")).resolve() template_root = TEMPLATE_DIR.resolve() @@ -2132,6 +9945,22 @@ def do_GET(self) -> None: }, ) return + if parsed.path == "/patch-swarm/console" or (parsed.path.startswith("/patch-swarm/runs/") and parsed.path.endswith("/console")): + query = parse_qs(parsed.query) + raw_run_dir = str((query.get("run_dir") or [""])[0]) + run_id = "" + parts = [part for part in parsed.path.split("/") if part] + if len(parts) == 4 and parts[0] == "patch-swarm" and parts[1] == "runs" and parts[3] == "console": + run_id = parts[2] + _payload, html_path = patch_swarm_console_render(run_id=run_id, raw_run_dir=raw_run_dir) + body = html_path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return if parsed.path == "/api/projects": with connect(db_path) as conn: init_db(conn) @@ -2225,6 +10054,47 @@ def do_GET(self) -> None: if parsed.path == "/api/factory": self.send_json(200, factory_run_list()) return + if parsed.path == "/api/patch-swarm/repos": + self.send_json(200, patch_swarm_discover_repos()) + return + if parsed.path == "/api/patch-swarm/runs": + self.send_json(200, patch_swarm_product_run_list()) + return + if parsed.path == "/api/patch-swarm/console": + query = parse_qs(parsed.query) + raw_run_dir = str((query.get("run_dir") or [""])[0]) + payload, _html_path = patch_swarm_console_render(raw_run_dir=raw_run_dir) + self.send_json(200, payload) + return + if parsed.path.startswith("/api/patch-swarm/runs/"): + parts = [part for part in parsed.path.split("/") if part] + if len(parts) == 5 and parts[4] == "console": + query = parse_qs(parsed.query) + raw_run_dir = str((query.get("run_dir") or [""])[0]) + payload, _html_path = patch_swarm_console_render(run_id=parts[3], raw_run_dir=raw_run_dir) + self.send_json(200, payload) + return + if len(parts) == 4: + self.send_json(200, patch_swarm_product_run_detail(parts[3])) + return + if parsed.path == "/api/dev-pipeline-studio": + query = parse_qs(parsed.query) + project_id = str((query.get("project") or [""])[0]) + template_id = str((query.get("template") or [""])[0]) + run_id = str((query.get("run_id") or [""])[0]) + self.send_json(200, dev_pipeline_studio_state(project_id=project_id, template_id=template_id, run_id=run_id)) + return + if parsed.path == "/api/demo-evidence/latest": + try: + latest_demo = latest_demo_video_path() + except AgentWorkAppError as exc: + self.send_json(404, {"error": str(exc)}) + return + self.send_response(302) + self.send_header("Location", artifact_url(latest_demo)) + self.send_header("Cache-Control", "no-store") + self.end_headers() + return if parsed.path == "/api/review": with connect(db_path) as conn: init_db(conn) @@ -2274,6 +10144,25 @@ def do_GET(self) -> None: init_db(conn) self.send_json(200, sync_from_agent_work(conn)) return + if parsed.path == "/api/codebase-intelligence": + import codebase_intelligence as ci + self.send_json(200, ci.inventory()) + return + if parsed.path == "/api/codebase-intelligence/graph": + import codebase_intelligence as ci + self.send_json(200, ci.build_graph()) + return + if parsed.path == "/api/codebase-intelligence/inspect": + import codebase_intelligence as ci + query = parse_qs(parsed.query) + file_path = str((query.get("path") or [""])[0]) + if not file_path: + self.send_json(400, {"error": "path query parameter is required"}) + return + result = ci.inspect_file(file_path) + status = 404 if "error" in result and result.get("error") in ("file not found", "path is not a file") else (403 if "error" in result and "outside" in str(result.get("error")) else 200) + self.send_json(status, result) + return path = safe_static_path(self.path) if not path.exists() or not path.is_file(): self.send_json(404, {"error": "Not found"}) @@ -2285,6 +10174,8 @@ def do_GET(self) -> None: self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) + except AgentWorkAppError as exc: + self.send_json(400, {"error": str(exc)}) except Exception as exc: self.send_json(500, {"error": str(exc)}) @@ -2292,6 +10183,26 @@ def do_POST(self) -> None: parsed = urlparse(self.path) try: payload = self.read_json() + if parsed.path == "/api/dev-pipeline-studio": + self.send_json(200, dev_pipeline_update(payload)) + return + if parsed.path == "/api/patch-swarm/runs": + self.send_json(201, patch_swarm_product_create_run(payload)) + return + if parsed.path.startswith("/api/patch-swarm/runs/"): + parts = [part for part in parsed.path.split("/") if part] + if len(parts) == 5 and parts[4] in {"approve", "reject", "apply"}: + if parts[4] == "approve": + self.send_json(200, patch_swarm_product_approve(parts[3], payload)) + return + if parts[4] == "reject": + self.send_json(200, patch_swarm_product_reject(parts[3], payload)) + return + self.send_json(200, patch_swarm_product_apply(parts[3], payload)) + return + if parsed.path == "/api/pipeline-runs": + self.send_json(201, dev_pipeline_start_pipeline_run(payload)) + return with connect(db_path) as conn: init_db(conn) if parsed.path == "/api/issues": @@ -2338,6 +10249,8 @@ def do_POST(self) -> None: self.send_json(200, decide_review(conn, issue_id, payload)) return self.send_json(404, {"error": "Not found"}) + except AgentWorkAppError as exc: + self.send_json(400, {"error": str(exc)}) except Exception as exc: self.send_json(500, {"error": str(exc)}) diff --git a/scripts/audio_quick_connect.sh b/scripts/audio_quick_connect.sh index 4cff77b..25ed7e0 100755 --- a/scripts/audio_quick_connect.sh +++ b/scripts/audio_quick_connect.sh @@ -101,6 +101,7 @@ main() { log "Attempting quick connect" result=$(connect_device "$address" || true) printf '%s\n' "$result" + sleep 2 if ! is_connected "$address"; then log "First connect attempt did not stick; retrying after a short disconnect" diff --git a/scripts/cento.sh b/scripts/cento.sh index 1a0923d..9628101 100755 --- a/scripts/cento.sh +++ b/scripts/cento.sh @@ -12,6 +12,7 @@ CLI_INTERACTIVE="$ROOT_DIR/scripts/cento_interactive.sh" PLATFORM_REPORT="$ROOT_DIR/scripts/platform_report.py" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/cento" CONFIG_FILE="$CONFIG_DIR/aliases.sh" +SECRETS_FILE="${CENTO_SECRETS_ENV:-$CONFIG_DIR/secrets.env}" CONFIG_TEMPLATE="$ROOT_DIR/templates/cento/aliases.sh" COMPLETION_TEMPLATE="$ROOT_DIR/scripts/completion/_cento" COMPLETION_DIR="$CONFIG_DIR/completions" @@ -45,6 +46,8 @@ Built-ins: install [all|zsh|tmux] Install cento shell and tmux integration run TOOL [args...] Run a registered tool by id + run fast|standard|thorough --task TEXT [--write PATH] + Create an execution contract; optionally run one local builder Routing: cento TOOL [args...] Run a registered tool directly @@ -65,9 +68,24 @@ Examples: cento install all cento install zsh cento install tmux + cento run fast --task "Fix app docs page" --write apps/foo/index.html + cento run fast --task "Fix app docs page" --write apps/foo/index.html --local-builder fixture --fixture-case valid --apply --validation smoke --commit none + cento run fast --task "Fix app docs page" --write apps/foo/index.html --runtime-profile codex-fast --apply --validation smoke --commit none + cento build init --task "Fixture docs page patch" --mode fast --write tests/fixtures/cento_build/app_page.html --route /fixture + cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime fixture --fixture-case valid --worktree --timeout 180 + cento build worker run .cento/builds//manifest.json --worker builder_1 --runtime-profile codex-fast --worktree + cento runtime check codex-fast + cento workset check tests/fixtures/cento_workset/workset.valid.json + cento workset run tests/fixtures/cento_workset/workset.valid.json --max-workers 2 --runtime-profile fixture-valid --apply sequential --validation smoke + cento workset execute tests/fixtures/cento_workset/workset.execute.fixture.json --max-parallel 3 --runtime fixture --integrate sequential --validation smoke + cento workset execute .cento/worksets/docs_page.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke + cento build bundle synthesize --manifest tests/fixtures/cento_build/manifest.valid.json --patch tests/fixtures/cento_build/patch.valid.diff + cento build integrate tests/fixtures/cento_build/manifest.valid.json --bundle .cento/builds/build_fixture_docs_page_001/integration/patch_bundle.json --dry-run cento mcp doctor cento mcp docs cento scan --query "mcp" + cento discord update + cento discord rerun cento kitty-theme-manager --list-custom cento monk cento cyber @@ -120,6 +138,14 @@ load_config() { source "$CONFIG_FILE" } +load_secrets_env() { + [[ -f "$SECRETS_FILE" ]] || return 0 + set -a + # shellcheck disable=SC1090 + source "$SECRETS_FILE" + set +a +} + choose_editor() { local candidate for candidate in "${VISUAL:-}" "${EDITOR:-}" nvim vim nano vi; do @@ -560,6 +586,11 @@ main() { ;; run) [[ $# -gt 0 ]] || cento_die "Usage: cento run TOOL [args...]" + case "${1:-}" in + --mode|fast|standard|thorough) + exec python3 "$ROOT_DIR/scripts/cento_run_mode.py" "$@" + ;; + esac local tool_id=$1 shift run_tool "$tool_id" "$@" @@ -577,4 +608,5 @@ main() { esac } +load_secrets_env main "$@" diff --git a/scripts/cento_build.py b/scripts/cento_build.py new file mode 100755 index 0000000..2a17bf7 --- /dev/null +++ b/scripts/cento_build.py @@ -0,0 +1,2749 @@ +#!/usr/bin/env python3 +"""Manifest-driven local build packages for Cento.""" + +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +import re +import shlex +import subprocess +import sys +import time +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from pathlib import PurePosixPath +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +MODES_PATH = ROOT / ".cento" / "modes.yaml" +RUNTIMES_PATH = ROOT / ".cento" / "runtimes.yaml" +BUILD_ROOT = ROOT / ".cento" / "builds" +DEFAULT_WORKER_TIMEOUT = 180 +SAFE_WORKER_ENV_ALLOWLIST = ( + "PATH", + "HOME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TZ", + "TMPDIR", +) + +SCHEMA_BUILD = "cento.build.v1" +SCHEMA_PATCH_BUNDLE = "cento.patch_bundle.v1" +SCHEMA_WORKER_ARTIFACT = "cento.worker_artifact.v1" +SCHEMA_INTEGRATION_RECEIPT = "cento.integration_receipt.v1" +SCHEMA_VALIDATION_RECEIPT = "cento.validation_receipt.v1" +SCHEMA_APPLY_RECEIPT = "cento.apply_receipt.v1" +SCHEMA_TASKSTREAM_EVIDENCE = "cento.taskstream_evidence.v1" + +DEFAULT_PROTECTED_PATHS = [ + ".env", + ".env.*", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", +] + +LOCKFILE_PATTERNS = [ + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "npm-shrinkwrap.json", + "Cargo.lock", + "Gemfile.lock", + "Pipfile.lock", + "poetry.lock", + "uv.lock", + "go.sum", +] + +DEFAULT_MODES: dict[str, dict[str, Any]] = { + "fast": { + "time_budget_minutes": 5, + "validation_tier": "smoke", + "ask_policy": "blockers_only", + "commit_policy": "none", + "push_policy": "none", + "max_workers": 0, + "max_files_changed": 3, + "repair_attempts": 0, + "risk_acceptance": "medium", + }, + "standard": { + "time_budget_minutes": 15, + "validation_tier": "focused", + "ask_policy": "one_batch_if_material", + "commit_policy": "local_commit", + "push_policy": "optional", + "max_workers": 2, + "max_files_changed": 8, + "repair_attempts": 1, + "risk_acceptance": "low_medium", + }, + "thorough": { + "time_budget_minutes": 30, + "validation_tier": "product", + "ask_policy": "requirements_or_options_first", + "commit_policy": "local_commit", + "push_policy": "branch", + "max_workers": 4, + "max_files_changed": None, + "repair_attempts": 3, + "risk_acceptance": "low", + }, +} + + +class BuildError(RuntimeError): + """Expected command failure.""" + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise BuildError(f"file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise BuildError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(payload, dict): + raise BuildError(f"expected JSON object in {path}") + return payload + + +def append_event(build_dir: Path, event_type: str, payload: dict[str, Any] | None = None) -> None: + build_dir.mkdir(parents=True, exist_ok=True) + row = {"ts": now_iso(), "event": event_type} + if payload: + row.update(payload) + with (build_dir / "events.ndjson").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, sort_keys=True) + "\n") + + +def run(command: list[str], *, cwd: Path = ROOT, input_text: str | None = None, timeout: int = 120) -> dict[str, Any]: + started = time.perf_counter() + proc = subprocess.run( + command, + cwd=cwd, + input=input_text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + return { + "command": command, + "exit_code": proc.returncode, + "status": "passed" if proc.returncode == 0 else "failed", + "stdout": proc.stdout, + "stderr": proc.stderr, + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + } + + +def git_value(args: list[str], fallback: str = "") -> str: + result = run(["git", *args], timeout=30) + if result["exit_code"] != 0: + return fallback + return str(result["stdout"]).strip() + + +def load_modes() -> dict[str, dict[str, Any]]: + if not MODES_PATH.exists(): + return DEFAULT_MODES + try: + import yaml # type: ignore + + data = yaml.safe_load(MODES_PATH.read_text(encoding="utf-8")) or {} + except Exception: + return DEFAULT_MODES + modes = data.get("modes") if isinstance(data, dict) else None + if not isinstance(modes, dict): + return DEFAULT_MODES + merged = dict(DEFAULT_MODES) + for name, mode in modes.items(): + if isinstance(name, str) and isinstance(mode, dict): + merged[name] = {**merged.get(name, {}), **mode} + return merged + + +def load_runtime_profiles() -> dict[str, dict[str, Any]]: + if not RUNTIMES_PATH.exists(): + return {} + try: + import yaml # type: ignore + + data = yaml.safe_load(RUNTIMES_PATH.read_text(encoding="utf-8")) or {} + except Exception as exc: + raise BuildError(f"failed to load runtime profiles from {rel(RUNTIMES_PATH)}: {exc}") from exc + runtimes = data.get("runtimes") if isinstance(data, dict) else None + if not isinstance(runtimes, dict): + raise BuildError(f"{rel(RUNTIMES_PATH)} must contain a runtimes mapping") + profiles: dict[str, dict[str, Any]] = {} + for name, profile in runtimes.items(): + if isinstance(name, str) and isinstance(profile, dict): + profiles[name] = dict(profile) + return profiles + + +def _positive_int(value: Any, field: str, errors: list[str]) -> int | None: + if value is None: + return None + if isinstance(value, bool): + errors.append(f"{field} must be a positive integer") + return None + try: + parsed = int(value) + except (TypeError, ValueError): + errors.append(f"{field} must be a positive integer") + return None + if parsed <= 0: + errors.append(f"{field} must be a positive integer") + return None + return parsed + + +def validate_runtime_profile(name: str, profile: Any) -> dict[str, Any]: + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(profile, dict): + return {"status": "failed", "errors": [f"runtime profile {name} must be an object"], "warnings": []} + + runtime_type = str(profile.get("type") or "") + if runtime_type not in {"fixture", "command"}: + errors.append("type must be fixture or command") + + timeout = _positive_int(profile.get("timeout_seconds"), "timeout_seconds", errors) + if timeout is None: + warnings.append(f"timeout_seconds missing; defaulting to {DEFAULT_WORKER_TIMEOUT}") + + for field in ("max_changed_files", "max_patch_lines"): + if field in profile and profile.get(field) is not None: + _positive_int(profile.get(field), field, errors) + + if runtime_type == "fixture": + fixture_case = str(profile.get("fixture_case") or "valid") + if fixture_case not in {"valid", "unowned", "protected", "delete", "lockfile", "binary"}: + errors.append("fixture_case must be one of valid, unowned, protected, delete, lockfile, binary") + + if runtime_type == "command": + argv = profile.get("argv") + if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item for item in argv): + errors.append("command runtime profiles require a non-empty argv list") + if "command" in profile: + errors.append("command runtime profiles must use argv arrays, not raw shell strings") + cwd = profile.get("cwd") + if cwd is not None and not isinstance(cwd, str): + errors.append("cwd must be a string when provided") + stdin_file = profile.get("stdin_file") + if stdin_file is not None and not isinstance(stdin_file, str): + errors.append("stdin_file must be a string when provided") + env_allowlist = profile.get("env_allowlist") + if env_allowlist is not None and ( + not isinstance(env_allowlist, list) or not all(isinstance(item, str) and item for item in env_allowlist) + ): + errors.append("env_allowlist must be a list of environment variable names") + if bool(profile.get("allow_network")): + warnings.append("allow_network is advisory only for local command profiles") + + return { + "status": "passed" if not errors else "failed", + "errors": errors, + "warnings": warnings, + } + + +def runtime_profile(name: str) -> dict[str, Any]: + profiles = load_runtime_profiles() + if name not in profiles: + available = ", ".join(sorted(profiles)) or "" + raise BuildError(f"runtime profile not found: {name}; available profiles: {available}") + profile = profiles[name] + result = validate_runtime_profile(name, profile) + if result["status"] != "passed": + raise BuildError(f"runtime profile {name} is invalid: " + "; ".join([str(item) for item in result["errors"]])) + return profile + + +def runtime_timeout(profile: dict[str, Any] | None, timeout: int | None) -> int: + if timeout is not None: + return int(timeout) + if profile is not None and profile.get("timeout_seconds") is not None: + return int(profile["timeout_seconds"]) + return DEFAULT_WORKER_TIMEOUT + + +def runtime_limit(profile: dict[str, Any] | None, field: str) -> int | None: + if profile is None or profile.get(field) is None: + return None + return int(profile[field]) + + +def runtime_context( + *, + manifest_path: Path, + build_dir: Path, + worker_dir: Path, + worktree: Path, + worker_id: str, +) -> dict[str, str]: + return { + "manifest": str(manifest_path), + "prompt": str(build_dir / "builder.prompt.md"), + "build_dir": str(build_dir), + "worker_dir": str(worker_dir), + "worktree": str(worktree), + "worker": worker_id, + "artifact_dir": str(worker_dir), + } + + +def format_runtime_value(value: Any, context: dict[str, str]) -> str: + text = str(value) + for key, replacement in context.items(): + text = text.replace("{" + key + "}", replacement) + return text + + +def build_worker_env( + manifest: dict[str, Any], + manifest_path: Path, + worker_id: str, + build_dir: Path, + worker_dir: Path, + worktree: Path, + env_allowlist: list[str] | None = None, +) -> dict[str, str]: + names = list(env_allowlist) if env_allowlist is not None else list(SAFE_WORKER_ENV_ALLOWLIST) + env = {key: os.environ[key] for key in names if key in os.environ} + env.update( + { + "CENTO_BUILD_ID": str(manifest.get("id") or ""), + "CENTO_MANIFEST": str(manifest_path), + "CENTO_WORKER_ID": worker_id, + "CENTO_ALLOWED_WRITE_PATHS": json.dumps(worker_write_paths(manifest, worker_id)), + "CENTO_MODE": str(manifest.get("mode") or ""), + "CENTO_WORKER_ARTIFACT_DIR": str(worker_dir), + "CENTO_BUILD_DIR": str(build_dir), + "CENTO_WORKTREE": str(worktree), + "CENTO_PROMPT": str(build_dir / "builder.prompt.md"), + } + ) + return env + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "_", value.strip().lower()).strip("_") + return slug[:52] or "task" + + +def normalize_path(value: str) -> str: + raw = value.strip() + if not raw: + raise BuildError("empty path is not allowed") + path = Path(raw).expanduser() + if path.is_absolute(): + try: + raw = path.resolve().relative_to(ROOT).as_posix() + except ValueError as exc: + raise BuildError(f"path must be inside repo: {value}") from exc + raw = raw.replace("\\", "/") + while raw.startswith("./"): + raw = raw[2:] + normalized = raw.rstrip("/") if raw != "." else raw + if normalized in {"", "."}: + raise BuildError("empty path is not allowed") + parts = PurePosixPath(normalized).parts + if ".." in parts: + raise BuildError(f"path traversal is not allowed: {value}") + if parts and parts[0] == ".git": + raise BuildError(f"git metadata path is not allowed: {value}") + return normalized + + +def normalize_patch_path(value: str) -> str: + raw = value.strip().replace("\\", "/") + if raw in {"/dev/null", "dev/null"}: + raise BuildError("/dev/null is not a repo path") + if raw.startswith("a/") or raw.startswith("b/"): + raw = raw[2:] + while raw.startswith("./"): + raw = raw[2:] + if not raw: + raise BuildError("empty patch path is not allowed") + if raw.startswith("/") or Path(raw).is_absolute(): + raise BuildError(f"absolute path in patch is not allowed: {value}") + parts = PurePosixPath(raw).parts + if ".." in parts: + raise BuildError(f"path traversal in patch is not allowed: {value}") + if parts and parts[0] == ".git": + raise BuildError(f"git metadata path in patch is not allowed: {value}") + if any(part == "" for part in parts): + raise BuildError(f"invalid patch path: {value}") + return PurePosixPath(raw).as_posix() + + +def normalize_paths(values: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + path = normalize_path(value) + if path not in seen: + seen.add(path) + result.append(path) + return result + + +def has_glob(value: str) -> bool: + return any(char in value for char in "*?[") + + +def path_exists(scope_path: str) -> bool: + if has_glob(scope_path): + return bool(list(ROOT.glob(scope_path))) + return (ROOT / scope_path).exists() + + +def path_matches(path: str, pattern: str) -> bool: + path = normalize_path(path) + pattern = normalize_path(pattern) + if pattern.endswith("/**"): + prefix = pattern[:-3].rstrip("/") + return path == prefix or path.startswith(prefix + "/") + if has_glob(pattern): + return fnmatch.fnmatch(path, pattern) + return path == pattern or path.startswith(pattern.rstrip("/") + "/") + + +def path_allowed(path: str, patterns: list[str]) -> bool: + return any(path_matches(path, pattern) for pattern in patterns) + + +def path_is_protected(path: str, patterns: list[str]) -> bool: + path = normalize_path(path) + basename = Path(path).name + for pattern in patterns: + normalized = normalize_path(pattern) + if path_matches(path, normalized): + return True + if "/" not in normalized and (fnmatch.fnmatch(basename, normalized) or basename == normalized): + return True + return False + + +def path_is_lockfile(path: str) -> bool: + basename = Path(normalize_path(path)).name + return any(fnmatch.fnmatch(basename, pattern) or basename == pattern for pattern in LOCKFILE_PATTERNS) + + +def path_explicitly_owned(path: str, patterns: list[str]) -> bool: + normalized = normalize_path(path) + for pattern in patterns: + candidate = normalize_path(pattern) + if candidate == normalized: + return True + if has_glob(candidate) and fnmatch.fnmatch(normalized, candidate): + return True + return False + + +def policy_allows_dirty_owned(policies: dict[str, Any]) -> bool: + if policies.get("allow_dirty_owned"): + return True + dirty_policy = policies.get("dirty_repo_policy") + if isinstance(dirty_policy, dict): + return str(dirty_policy.get("owned_dirty") or "").lower() in {"allow", "allowed", "allow_and_preserve"} + return False + + +def policy_allows_deletes(policies: dict[str, Any]) -> bool: + return bool(policies.get("allow_deletes") or policies.get("allow_file_deletes")) + + +def policy_allows_creates(policies: dict[str, Any]) -> bool: + return bool(policies.get("allow_creates") or policies.get("allow_file_creates")) + + +def status_path(line: str) -> str: + path = line[3:] + if " -> " in path: + path = path.split(" -> ", 1)[1] + return path.strip() + + +def git_status_lines() -> list[str]: + result = run(["git", "status", "--porcelain=v1", "--untracked-files=all"], timeout=30) + if result["exit_code"] != 0: + raise BuildError(str(result["stderr"]).strip() or "git status failed") + return [line for line in str(result["stdout"]).splitlines() if line.strip()] + + +def git_status_lines_for(cwd: Path) -> list[str]: + result = run(["git", "status", "--porcelain=v1", "--untracked-files=all"], cwd=cwd, timeout=30) + if result["exit_code"] != 0: + raise BuildError(str(result["stderr"]).strip() or "git status failed") + return [line for line in str(result["stdout"]).splitlines() if line.strip()] + + +def status_paths(lines: list[str]) -> list[str]: + paths: list[str] = [] + for line in lines: + try: + paths.append(normalize_path(status_path(line))) + except BuildError: + paths.append(status_path(line)) + return sorted(set(paths)) + + +def dirty_paths_for(write_paths: list[str]) -> tuple[list[str], list[str]]: + dirty_owned: list[str] = [] + dirty_unrelated: list[str] = [] + for line in git_status_lines(): + changed = normalize_path(status_path(line)) + if path_allowed(changed, write_paths): + dirty_owned.append(changed) + else: + dirty_unrelated.append(changed) + return sorted(set(dirty_owned)), sorted(set(dirty_unrelated)) + + +def derived_read_paths(write_paths: list[str]) -> list[str]: + reads: list[str] = [] + for item in write_paths: + path = Path(item) + if has_glob(item): + reads.append(item) + elif "." in path.name and path.parent.as_posix() not in {"", "."}: + reads.append(path.parent.as_posix().rstrip("/") + "/**") + else: + reads.append(item.rstrip("/") + "/**") + return sorted(set(reads)) + + +def build_dir_for_manifest(manifest: dict[str, Any], manifest_path: Path | None = None) -> Path: + manifest_id = str(manifest.get("id") or "unknown_build") + if manifest_path is not None: + try: + resolved = manifest_path.resolve() + root = BUILD_ROOT.resolve() + if root == resolved.parent or root in resolved.parent.parents: + return resolved.parent + except OSError: + pass + return BUILD_ROOT / manifest_id + + +def manifest_write_paths(manifest: dict[str, Any]) -> list[str]: + scope = manifest.get("scope") if isinstance(manifest.get("scope"), dict) else {} + return normalize_paths([str(item) for item in scope.get("write_paths") or []]) + + +def manifest_read_paths(manifest: dict[str, Any]) -> list[str]: + scope = manifest.get("scope") if isinstance(manifest.get("scope"), dict) else {} + return normalize_paths([str(item) for item in scope.get("read_paths") or []]) + + +def manifest_protected_paths(manifest: dict[str, Any]) -> list[str]: + scope = manifest.get("scope") if isinstance(manifest.get("scope"), dict) else {} + protected = [str(item) for item in scope.get("protected_paths") or []] + return normalize_paths(protected or DEFAULT_PROTECTED_PATHS) + + +def manifest_routes(manifest: dict[str, Any]) -> list[str]: + scope = manifest.get("scope") if isinstance(manifest.get("scope"), dict) else {} + return [str(item) for item in scope.get("routes") or []] + + +def mode_policy(mode: dict[str, Any]) -> dict[str, Any]: + keys = [ + "time_budget_minutes", + "validation_tier", + "max_workers", + "max_files_changed", + "repair_attempts", + "risk_acceptance", + "behavior", + ] + return {key: mode.get(key) for key in keys if key in mode} + + +def create_manifest(args: argparse.Namespace) -> dict[str, Any]: + modes = load_modes() + mode_name = args.mode + if mode_name not in modes: + raise BuildError(f"unknown mode: {mode_name}") + mode = modes[mode_name] + write_paths = normalize_paths(args.write) + read_paths = normalize_paths(args.read) if args.read else derived_read_paths(write_paths) + protected_paths = normalize_paths(args.protect or DEFAULT_PROTECTED_PATHS) + build_id = args.id or f"build_{slugify(args.task)}_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + artifact_dir = f".cento/builds/{build_id}/workers/builder_1" + validation_tier = args.validation or str(mode.get("validation_tier") or "smoke") + base_ref = git_value(["rev-parse", "HEAD"], "HEAD") + + return { + "schema_version": SCHEMA_BUILD, + "id": build_id, + "task": { + "title": args.task, + "description": args.description or args.task, + }, + "mode": mode_name, + "mode_policy": mode_policy(mode), + "source": { + "base_ref": base_ref, + "created_at": now_iso(), + }, + "scope": { + "routes": [str(route) for route in args.route], + "read_paths": read_paths, + "write_paths": write_paths, + "protected_paths": protected_paths, + }, + "policies": { + "ask_policy": mode.get("ask_policy", "blockers_only"), + "dirty_repo_policy": mode.get( + "dirty_repo_policy", + {"unrelated_dirty": "allow_and_preserve", "owned_dirty": "block"}, + ), + "commit_policy": mode.get("commit_policy", "none"), + "push_policy": mode.get("push_policy", "none"), + "allow_unowned_changes": False, + "allow_protected_changes": False, + "allow_dirty_owned": bool(args.allow_dirty_owned), + "allow_creates": False, + "allow_deletes": False, + }, + "validation": { + "tier": validation_tier, + "commands": [ + { + "name": "diff_check", + "command": "git diff --check", + } + ], + }, + "workers": [ + { + "id": "builder_1", + "type": "local", + "runtime": "codex", + "node": None, + "role": "builder", + "write_paths": write_paths, + "artifact_dir": artifact_dir, + } + ], + "acceptance": [ + "Only owned paths are modified.", + "Patch applies cleanly.", + "Integration receipt is written.", + "Validation receipt is written.", + ], + } + + +def validate_manifest(manifest: dict[str, Any], *, allow_dirty_owned: bool = False) -> dict[str, Any]: + modes = load_modes() + errors: list[str] = [] + warnings: list[str] = [] + + if manifest.get("schema_version") != SCHEMA_BUILD: + errors.append(f"schema_version must be {SCHEMA_BUILD}") + manifest_id = manifest.get("id") + if not isinstance(manifest_id, str) or not manifest_id: + errors.append("id is required") + task = manifest.get("task") + if not isinstance(task, dict) or not task.get("title"): + errors.append("task.title is required") + mode_name = manifest.get("mode") + if not isinstance(mode_name, str) or mode_name not in modes: + errors.append(f"mode must exist in .cento/modes.yaml: {mode_name}") + source = manifest.get("source") + if not isinstance(source, dict) or not source.get("base_ref") or not source.get("created_at"): + errors.append("source.base_ref and source.created_at are required") + scope = manifest.get("scope") + if not isinstance(scope, dict): + errors.append("scope is required") + scope = {} + routes = scope.get("routes") + if not isinstance(routes, list): + errors.append("scope.routes must be a list") + write_paths = manifest_write_paths(manifest) + read_paths = manifest_read_paths(manifest) + protected_paths = manifest_protected_paths(manifest) + policies = manifest.get("policies") + if not isinstance(policies, dict): + policies = {} + if not write_paths: + errors.append("scope.write_paths must include at least one owned path") + if not isinstance(scope.get("read_paths"), list): + errors.append("scope.read_paths must be a list") + for path in write_paths: + if not path_exists(path) and not policy_allows_creates(policies): + errors.append(f"owned write path does not exist: {path}") + if path_is_protected(path, protected_paths): + errors.append(f"owned write path is protected: {path}") + for path in protected_paths: + if path_allowed(path, write_paths): + errors.append(f"protected path cannot be owned: {path}") + raw_policies = manifest.get("policies") + if not isinstance(raw_policies, dict): + errors.append("policies is required") + for key in ("ask_policy", "dirty_repo_policy", "commit_policy", "push_policy"): + if key not in policies: + errors.append(f"policies.{key} is required") + for key in ("allow_unowned_changes", "allow_protected_changes"): + if key not in policies or not isinstance(policies.get(key), bool): + errors.append(f"policies.{key} must be a boolean") + validation = manifest.get("validation") + if not isinstance(validation, dict): + errors.append("validation is required") + elif "tier" not in validation: + errors.append("validation.tier is required") + workers = manifest.get("workers") + if not isinstance(workers, list) or not workers: + errors.append("workers must include at least one builder") + else: + for worker in workers: + if not isinstance(worker, dict) or not worker.get("id"): + errors.append("each worker must include id") + continue + worker_paths = worker.get("write_paths") + if not isinstance(worker_paths, list) or not worker_paths: + errors.append(f"workers[{worker.get('id')}].write_paths must include owned paths") + else: + normalized_worker_paths = normalize_paths([str(item) for item in worker_paths]) + for worker_path in normalized_worker_paths: + if not path_allowed(worker_path, write_paths): + errors.append(f"worker {worker.get('id')} owns path outside manifest scope: {worker_path}") + if read_paths and not isinstance(read_paths, list): + errors.append("scope.read_paths must be a list") + + if write_paths: + try: + dirty_owned, _dirty_unrelated = dirty_paths_for(write_paths) + except BuildError as exc: + warnings.append(str(exc)) + dirty_owned = [] + if dirty_owned: + if allow_dirty_owned or policy_allows_dirty_owned(policies): + warnings.append("dirty owned paths present (allow_dirty_owned): " + ", ".join(dirty_owned)) + else: + errors.append("dirty owned paths present: " + ", ".join(dirty_owned)) + + return { + "status": "passed" if not errors else "failed", + "errors": errors, + "warnings": warnings, + } + + +def render_builder_prompt(manifest: dict[str, Any]) -> str: + task = manifest.get("task") if isinstance(manifest.get("task"), dict) else {} + workers = manifest.get("workers") if isinstance(manifest.get("workers"), list) else [] + worker = workers[0] if workers and isinstance(workers[0], dict) else {} + write_paths = manifest_write_paths(manifest) + read_paths = manifest_read_paths(manifest) + protected_paths = manifest_protected_paths(manifest) + routes = manifest_routes(manifest) + validation = manifest.get("validation") if isinstance(manifest.get("validation"), dict) else {} + commands = validation.get("commands") if isinstance(validation.get("commands"), list) else [] + + lines = [ + "# Cento Builder Prompt", + "", + "You are a Cento Builder working from a manifest-owned local work package.", + "", + "## Task", + f"- Manifest: {manifest.get('id', '')}", + f"- Mode: {manifest.get('mode', '')}", + f"- Title: {task.get('title', '')}", + f"- Description: {task.get('description', task.get('title', ''))}", + "", + "## Scope", + "- Routes: " + (", ".join(routes) if routes else ""), + "- Owned write paths:", + *[f" - {path}" for path in (write_paths or [""])], + "- Read paths:", + *[f" - {path}" for path in (read_paths or [""])], + "- Protected paths:", + *[f" - {path}" for path in protected_paths], + "", + "## Builder Rules", + "- Inspect read paths as needed.", + "- Edit only owned write paths.", + "- You must not edit unowned paths.", + "- Stop and request scope expansion if an unowned file is required.", + "- Preserve dirty unrelated files and staged unrelated files.", + "- Do not commit, push, modify protected files, change lockfiles, or silently expand scope.", + "- Do not hide validation failures.", + "", + "## Required Output", + f"- Write artifacts under `{worker.get('artifact_dir', '')}`.", + "- Produce `patch.diff` from current base with:", + " `git diff -- > patch.diff`", + "- Produce `patch_bundle.json` with touched paths, owned paths, unowned paths, protected paths touched, and summary.", + "- Produce `worker_artifact.json` with status, manifest id, worker id, touched paths, assumptions, validation, risks, and patch path.", + "- Produce a short `handoff.md` with changed files, assumptions, validation, and risks.", + "", + "## Validation Commands", + ] + if commands: + for item in commands: + if isinstance(item, dict): + lines.append(f"- {item.get('name', 'command')}: `{item.get('command', '')}`") + else: + lines.append("- No validation commands declared.") + return "\n".join(lines).rstrip() + "\n" + + +def parse_diff_path(raw: str) -> str | None: + raw = raw.strip() + if not raw or raw == "/dev/null": + return None + try: + parts = shlex.split(raw) + if parts: + raw = parts[0] + except ValueError: + raw = raw.split("\t", 1)[0].split(" ", 1)[0] + if raw in {"/dev/null", "dev/null"}: + return None + return normalize_patch_path(raw) + + +def analyze_patch(patch_path: Path) -> dict[str, Any]: + try: + text = patch_path.read_text(encoding="utf-8", errors="replace") + except FileNotFoundError as exc: + raise BuildError(f"patch file not found: {patch_path}") from exc + lines = text.splitlines() + paths: set[str] = set() + path_errors: list[str] = [] + delete_paths: set[str] = set() + symlink_paths: set[str] = set() + submodule_paths: set[str] = set() + renames: list[dict[str, str]] = [] + current_paths: set[str] = set() + rename_from: str | None = None + binary = False + for line in lines: + if line.startswith("diff --git "): + current_paths = set() + rename_from = None + tail = line[len("diff --git ") :] + if tail.startswith("a/") and " b/" in tail: + left, right = tail.split(" b/", 1) + for raw in (left, "b/" + right): + try: + parsed = parse_diff_path(raw) + except BuildError as exc: + path_errors.append(str(exc)) + continue + if parsed: + paths.add(parsed) + current_paths.add(parsed) + continue + if line.startswith("Binary files") or line.startswith("GIT binary patch"): + binary = True + continue + if line.startswith("rename from "): + try: + rename_from = normalize_patch_path(line[len("rename from ") :]) + paths.add(rename_from) + current_paths.add(rename_from) + except BuildError as exc: + path_errors.append(str(exc)) + continue + if line.startswith("rename to "): + try: + rename_to = normalize_patch_path(line[len("rename to ") :]) + paths.add(rename_to) + current_paths.add(rename_to) + if rename_from: + renames.append({"from": rename_from, "to": rename_to}) + except BuildError as exc: + path_errors.append(str(exc)) + continue + if line.startswith("deleted file mode "): + delete_paths.update(current_paths) + continue + if line.startswith("old mode 120000") or line.startswith("new file mode 120000"): + symlink_paths.update(current_paths) + continue + if line.startswith("Subproject commit "): + submodule_paths.update(current_paths) + continue + if line.startswith("--- ") or line.startswith("+++ "): + raw_path = line[4:] + try: + parsed = parse_diff_path(raw_path) + except BuildError as exc: + if raw_path.strip() not in {"/dev/null", "dev/null"}: + path_errors.append(str(exc)) + parsed = None + if parsed: + paths.add(parsed) + current_paths.add(parsed) + elif line.startswith("+++ "): + delete_paths.update(current_paths) + return { + "paths": sorted(paths), + "path_errors": path_errors, + "delete_paths": sorted(delete_paths), + "renames": renames, + "binary": binary, + "symlink_paths": sorted(symlink_paths), + "submodule_paths": sorted(submodule_paths), + } + + +def extract_patch_paths(patch_path: Path) -> list[str]: + return list(analyze_patch(patch_path)["paths"]) + + +def patch_policy_rejections( + analysis: dict[str, Any], + write_paths: list[str], + protected_paths: list[str], + policies: dict[str, Any], +) -> list[str]: + rejections: list[str] = [] + path_errors = [str(item) for item in analysis.get("path_errors") or []] + if path_errors: + rejections.extend(path_errors) + if analysis.get("binary"): + rejections.append("binary patches are rejected") + symlink_paths = [str(item) for item in analysis.get("symlink_paths") or []] + if symlink_paths: + rejections.append("symlink patch paths are rejected: " + ", ".join(symlink_paths)) + submodule_paths = [str(item) for item in analysis.get("submodule_paths") or []] + if submodule_paths: + rejections.append("submodule patch paths are rejected: " + ", ".join(submodule_paths)) + delete_paths = [str(item) for item in analysis.get("delete_paths") or []] + if delete_paths and not policy_allows_deletes(policies): + rejections.append("delete patches are rejected unless policies.allow_deletes is true: " + ", ".join(delete_paths)) + bad_renames: list[str] = [] + for rename in analysis.get("renames") or []: + source = str(rename.get("from") or "") + destination = str(rename.get("to") or "") + if not source or not destination or not path_allowed(source, write_paths) or not path_allowed(destination, write_paths): + bad_renames.append(f"{source}->{destination}") + if bad_renames: + rejections.append("renames require owned source and destination: " + ", ".join(bad_renames)) + lockfiles = [ + path + for path in [str(item) for item in analysis.get("paths") or []] + if path_is_lockfile(path) and not path_explicitly_owned(path, write_paths) + ] + if lockfiles: + rejections.append("lockfile changes require explicit ownership: " + ", ".join(sorted(set(lockfiles)))) + return rejections + + +def load_optional_json(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + return read_json(path) + + +def artifact_worker_id(manifest: dict[str, Any]) -> str: + workers = manifest.get("workers") if isinstance(manifest.get("workers"), list) else [] + if workers and isinstance(workers[0], dict) and workers[0].get("id"): + return str(workers[0]["id"]) + return "builder_1" + + +def manifest_worker(manifest: dict[str, Any], worker_id: str) -> dict[str, Any]: + workers = manifest.get("workers") if isinstance(manifest.get("workers"), list) else [] + for worker in workers: + if isinstance(worker, dict) and str(worker.get("id") or "") == worker_id: + return worker + raise BuildError(f"worker not found in manifest: {worker_id}") + + +def worker_artifact_dir(manifest: dict[str, Any], worker_id: str, build_dir: Path) -> Path: + try: + worker = manifest_worker(manifest, worker_id) + except BuildError: + worker = {} + configured = worker.get("artifact_dir") if isinstance(worker, dict) else None + if isinstance(configured, str) and configured: + path = Path(configured) + return path if path.is_absolute() else ROOT / path + return build_dir / "workers" / worker_id + + +def worker_write_paths(manifest: dict[str, Any], worker_id: str) -> list[str]: + worker = manifest_worker(manifest, worker_id) + paths = worker.get("write_paths") if isinstance(worker.get("write_paths"), list) else manifest_write_paths(manifest) + return normalize_paths([str(path) for path in paths]) + + +def synthesize_patch_bundle( + manifest: dict[str, Any], + patch_path: Path, + touched_paths: list[str], + build_dir: Path, + *, + out_path: Path | None = None, + worker_id: str | None = None, + summary: str = "Synthesized from patch file for local dry-run integration.", +) -> Path: + write_paths = manifest_write_paths(manifest) + protected_paths = manifest_protected_paths(manifest) + patch_sha = hashlib.sha256(patch_path.read_bytes()).hexdigest() + manifest_id = str(manifest.get("id") or "build") + bundle_worker_id = worker_id or artifact_worker_id(manifest) + bundle = { + "schema_version": SCHEMA_PATCH_BUNDLE, + "id": f"bundle_{slugify(manifest_id)}_{slugify(bundle_worker_id)}_{patch_sha[:12]}", + "manifest_id": manifest.get("id"), + "worker_id": bundle_worker_id, + "base_ref": (manifest.get("source") or {}).get("base_ref") if isinstance(manifest.get("source"), dict) else "HEAD", + "patch_file": rel(patch_path), + "patch_sha256": patch_sha, + "touched_paths": touched_paths, + "owned_paths": write_paths, + "unowned_paths": [path for path in touched_paths if not path_allowed(path, write_paths)], + "protected_paths_touched": [path for path in touched_paths if path_is_protected(path, protected_paths)], + "summary": summary, + "requires_integration": True, + } + path = out_path or build_dir / "integration" / "patch_bundle.json" + if not path.is_absolute(): + path = ROOT / path + write_json(path, bundle) + return path + + +def resolve_bundle_patch_path(bundle: dict[str, Any], bundle_path: Path | None = None) -> Path: + patch_file = bundle.get("patch_file") + if not isinstance(patch_file, str) or not patch_file: + raise BuildError("patch bundle patch_file is required") + patch_path = Path(patch_file) + if not patch_path.is_absolute(): + root_candidate = ROOT / patch_path + bundle_candidate = bundle_path.parent / patch_path if bundle_path is not None else root_candidate + patch_path = root_candidate if root_candidate.exists() else bundle_candidate + return patch_path + + +def validate_patch_bundle( + bundle: dict[str, Any], + manifest: dict[str, Any], + bundle_path: Path | None, + analysis: dict[str, Any] | None = None, +) -> dict[str, Any]: + errors: list[str] = [] + warnings: list[str] = [] + if bundle.get("schema_version") != SCHEMA_PATCH_BUNDLE: + errors.append("patch bundle schema mismatch") + if not isinstance(bundle.get("id"), str) or not bundle.get("id"): + warnings.append("patch bundle id is missing") + if bundle.get("manifest_id") != manifest.get("id"): + errors.append("patch bundle manifest id mismatch") + if not isinstance(bundle.get("worker_id"), str) or not bundle.get("worker_id"): + errors.append("patch bundle worker_id is required") + elif bundle.get("worker_id") != artifact_worker_id(manifest): + errors.append("patch bundle worker id mismatch") + for key in ("patch_file", "touched_paths", "owned_paths"): + if key not in bundle: + errors.append(f"patch bundle {key} is required") + touched_paths = normalize_paths([str(path) for path in bundle.get("touched_paths") or []]) + write_paths = manifest_write_paths(manifest) + protected_paths = manifest_protected_paths(manifest) + unowned_paths = [path for path in touched_paths if not path_allowed(path, write_paths)] + protected_touched = [path for path in touched_paths if path_is_protected(path, protected_paths)] + if unowned_paths: + errors.append("patch bundle touches unowned paths: " + ", ".join(unowned_paths)) + if protected_touched: + errors.append("patch bundle touches protected paths: " + ", ".join(protected_touched)) + if analysis is not None: + actual_paths = normalize_paths([str(path) for path in analysis.get("paths") or []]) + if sorted(actual_paths) != sorted(touched_paths): + errors.append( + "patch bundle touched_paths do not match patch: " + + ", ".join(sorted(set(actual_paths).symmetric_difference(touched_paths))) + ) + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + errors.extend(patch_policy_rejections(analysis, write_paths, protected_paths, policies)) + try: + patch_path = resolve_bundle_patch_path(bundle, bundle_path) + except BuildError as exc: + errors.append(str(exc)) + else: + if not patch_path.exists(): + errors.append(f"patch bundle patch_file not found: {patch_path}") + elif bundle.get("patch_sha256"): + actual_sha = hashlib.sha256(patch_path.read_bytes()).hexdigest() + if str(bundle.get("patch_sha256")) != actual_sha: + errors.append("patch bundle patch_sha256 mismatch") + return {"status": "passed" if not errors else "failed", "errors": errors, "warnings": warnings} + + +def shell_validation_command(command: str) -> list[str]: + return ["bash", "-lc", command] + + +def run_validation_receipt( + manifest: dict[str, Any], + build_dir: Path, + *, + skipped: bool = False, + reason: str = "", + cwd: Path = ROOT, +) -> dict[str, Any]: + validation = manifest.get("validation") if isinstance(manifest.get("validation"), dict) else {} + commands = validation.get("commands") if isinstance(validation.get("commands"), list) else [] + validation_dir = build_dir / "validation" + validation_dir.mkdir(parents=True, exist_ok=True) + records: list[dict[str, Any]] = [] + if skipped: + for item in commands: + if not isinstance(item, dict): + continue + records.append( + { + "name": str(item.get("name") or "command"), + "command": str(item.get("command") or ""), + "exit_code": None, + "status": "skipped", + "reason": reason, + } + ) + status = "skipped" + else: + status = "passed" + for item in commands: + if not isinstance(item, dict): + continue + name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", str(item.get("name") or "command")).strip("_") or "command" + command = str(item.get("command") or "") + result = run(shell_validation_command(command), cwd=cwd, timeout=int(item.get("timeout_seconds") or 120)) + stdout_path = validation_dir / f"{name}.stdout" + stderr_path = validation_dir / f"{name}.stderr" + stdout_path.write_text(str(result["stdout"]), encoding="utf-8") + stderr_path.write_text(str(result["stderr"]), encoding="utf-8") + if result["exit_code"] != 0: + status = "failed" + records.append( + { + "name": name, + "command": command, + "exit_code": result["exit_code"], + "status": result["status"], + "stdout_path": rel(stdout_path), + "stderr_path": rel(stderr_path), + } + ) + receipt = { + "schema_version": SCHEMA_VALIDATION_RECEIPT, + "manifest_id": manifest.get("id"), + "tier": validation.get("tier", "smoke"), + "status": status, + "commands": records, + "artifacts": [], + "written_at": now_iso(), + } + write_json(build_dir / "validation_receipt.json", receipt) + append_event(build_dir, "validation_receipt_written", {"status": status}) + return receipt + + +def add_check(checks: list[dict[str, Any]], name: str, status: str, details: str = "") -> None: + row = {"name": name, "status": status} + if details: + row["details"] = details + checks.append(row) + + +def base_ref_matches(expected: str, current: str, *, allow_head: bool = False) -> bool: + if not expected: + return False + if expected == "HEAD": + return allow_head + return current == expected + + +def fixture_or_dev_path(path: Path | None) -> bool: + if path is None: + return False + try: + return rel(path).startswith("tests/fixtures/") + except OSError: + return False + + +def write_integration_receipt(build_dir: Path, receipt: dict[str, Any]) -> Path: + latest = build_dir / "integration_receipt.json" + write_json(latest, receipt) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + write_json(build_dir / "integration" / "receipts" / f"{stamp}.json", receipt) + return latest + + +def validate_worker_artifact( + artifact: dict[str, Any], + manifest: dict[str, Any] | None = None, + *, + allow_head_base: bool = False, +) -> dict[str, Any]: + errors: list[str] = [] + warnings: list[str] = [] + required = ["schema_version", "manifest_id", "worker_id", "role", "status", "touched_paths"] + for key in required: + if key not in artifact: + errors.append(f"worker artifact {key} is required") + if artifact.get("schema_version") != SCHEMA_WORKER_ARTIFACT: + errors.append("worker artifact schema mismatch") + artifact_status = str(artifact.get("status") or "") + if artifact_status not in {"completed", "accepted"}: + errors.append(f"worker artifact status is not completed: {artifact_status or ''}") + touched = artifact.get("touched_paths") + if not isinstance(touched, list): + errors.append("worker artifact touched_paths must be a list") + touched_paths: list[str] = [] + else: + touched_paths = normalize_paths([str(path) for path in touched]) + + if manifest is not None: + if artifact.get("manifest_id") != manifest.get("id"): + errors.append("worker artifact manifest id mismatch") + if artifact.get("worker_id") != artifact_worker_id(manifest): + errors.append("worker artifact worker id mismatch") + write_paths = manifest_write_paths(manifest) + protected_paths = manifest_protected_paths(manifest) + unowned = [path for path in touched_paths if not path_allowed(path, write_paths)] + protected_touched = [path for path in touched_paths if path_is_protected(path, protected_paths)] + if unowned: + errors.append("worker artifact touches unowned paths: " + ", ".join(unowned)) + if protected_touched: + errors.append("worker artifact touches protected paths: " + ", ".join(protected_touched)) + artifact_base = str(artifact.get("base_ref") or "") + manifest_base = str((manifest.get("source") or {}).get("base_ref") or "") if isinstance(manifest.get("source"), dict) else "" + if artifact_base and manifest_base and not base_ref_matches(artifact_base, manifest_base, allow_head=allow_head_base): + errors.append(f"worker artifact base ref mismatch: artifact={artifact_base} manifest={manifest_base}") + return {"status": "passed" if not errors else "failed", "errors": errors, "warnings": warnings} + + +def add_rejections(checks: list[dict[str, Any]], rejections: list[str], name: str, errors: list[str]) -> None: + if errors: + add_check(checks, name, "failed", "; ".join(errors)) + rejections.extend(errors) + else: + add_check(checks, name, "passed") + + +def create_isolated_worktree(base_ref: str, build_id: str) -> tuple[Path | None, dict[str, Any]]: + temp_root = ROOT / "workspace" / "tmp" / "cento-build-worktrees" + temp_root.mkdir(parents=True, exist_ok=True) + worktree_path = Path(tempfile.mkdtemp(prefix=f"{slugify(build_id)}-", dir=temp_root)) + worktree_path.rmdir() + result = run(["git", "worktree", "add", "--detach", str(worktree_path), base_ref], timeout=120) + if result["exit_code"] != 0: + return None, result + return worktree_path, result + + +def remove_isolated_worktree(worktree_path: Path | None) -> dict[str, Any] | None: + if worktree_path is None: + return None + return run(["git", "worktree", "remove", "--force", str(worktree_path)], timeout=120) + + +def write_worker_handoff( + path: Path, + *, + status: str, + runtime: str, + touched_paths: list[str], + errors: list[str], + warnings: list[str], +) -> None: + lines = [ + "# Cento Worker Handoff", + "", + f"- Status: {status}", + f"- Runtime: {runtime}", + "", + "## Changed Files", + ] + lines.extend([f"- {item}" for item in touched_paths] or ["- "]) + lines.extend(["", "## Risks / Rejections"]) + lines.extend([f"- {item}" for item in errors] or ["- "]) + if warnings: + lines.extend(["", "## Warnings"]) + lines.extend([f"- {item}" for item in warnings]) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + + +def fixture_append_or_replace(path: Path, marker: str) -> None: + text = path.read_text(encoding="utf-8", errors="replace") + replacements = [ + ("Draft docs page.", "Production-ready docs page."), + ("Cheap Spark/Codex workers are now an explicit coordination option.", "Cheap Spark/Codex workers remain an explicit coordination option."), + ("debug copy", "production copy"), + ] + for old, new in replacements: + if old in text: + path.write_text(text.replace(old, new, 1), encoding="utf-8") + return + path.write_text(text.rstrip() + f"\n{marker}\n", encoding="utf-8") + + +def run_fixture_worker_runtime( + manifest: dict[str, Any], + worker_id: str, + cwd: Path, + runtime: str, + fixture_case: str = "valid", +) -> dict[str, Any]: + write_paths = worker_write_paths(manifest, worker_id) + protected_paths = manifest_protected_paths(manifest) + target = cwd / write_paths[0] + target.parent.mkdir(parents=True, exist_ok=True) + case = fixture_case + if runtime.startswith("fixture-"): + case = runtime.removeprefix("fixture-") + + if case == "valid": + if not target.exists(): + raise BuildError(f"fixture target does not exist in worker worktree: {write_paths[0]}") + fixture_append_or_replace(target, "") + return {"runtime": runtime, "fixture_case": case, "exit_code": 0, "status": "passed", "stdout": "fixture patch written\n", "stderr": ""} + + if case == "unowned": + unowned = "README.md" if not path_allowed("README.md", write_paths) else "CLUSTER_NOTICE.md" + fixture_append_or_replace(cwd / unowned, "") + return {"runtime": runtime, "fixture_case": case, "exit_code": 0, "status": "passed", "stdout": "fixture unowned patch written\n", "stderr": ""} + + if case == "protected": + protected = protected_paths[0] if protected_paths else ".env.fixture" + protected_path = cwd / protected + protected_path.parent.mkdir(parents=True, exist_ok=True) + protected_path.write_text("CENTO_FIXTURE_PROTECTED=1\n", encoding="utf-8") + return {"runtime": runtime, "fixture_case": case, "exit_code": 0, "status": "passed", "stdout": "fixture protected patch written\n", "stderr": ""} + + if case == "binary": + target.write_bytes(b"\x00CENTO_BINARY_FIXTURE\n") + return {"runtime": runtime, "fixture_case": case, "exit_code": 0, "status": "passed", "stdout": "fixture binary patch written\n", "stderr": ""} + + if case == "delete": + if not target.exists(): + raise BuildError(f"fixture target does not exist in worker worktree: {write_paths[0]}") + target.unlink() + return {"runtime": runtime, "fixture_case": case, "exit_code": 0, "status": "passed", "stdout": "fixture delete patch written\n", "stderr": ""} + + if case == "lockfile": + lockfile = next((path for path in LOCKFILE_PATTERNS if (cwd / path).exists()), "package-lock.json") + fixture_path = cwd / lockfile + fixture_path.parent.mkdir(parents=True, exist_ok=True) + if fixture_path.exists(): + fixture_append_or_replace(fixture_path, "cento fixture lockfile touch") + else: + fixture_path.write_text('{"cento_fixture": true}\n', encoding="utf-8") + return {"runtime": runtime, "fixture_case": case, "exit_code": 0, "status": "passed", "stdout": "fixture lockfile patch written\n", "stderr": ""} + + raise BuildError(f"unknown fixture case: {case}") + + +def run_worker_runtime( + manifest: dict[str, Any], + manifest_path: Path, + worker_id: str, + runtime: str, + cwd: Path, + build_dir: Path, + worker_dir: Path, + timeout: int, + fixture_case: str = "valid", + command_template: str | None = None, + profile_name: str | None = None, + profile_config: dict[str, Any] | None = None, + allow_unsafe_command: bool = False, +) -> dict[str, Any]: + prompt_path = build_dir / "builder.prompt.md" + if not prompt_path.exists(): + prompt_path.write_text(render_builder_prompt(manifest), encoding="utf-8") + + context = runtime_context( + manifest_path=manifest_path, + build_dir=build_dir, + worker_dir=worker_dir, + worktree=cwd, + worker_id=worker_id, + ) + + if profile_config is not None: + runtime = str(profile_config.get("type") or runtime) + profile_env_allowlist = profile_config.get("env_allowlist") + env_allowlist = [str(item) for item in profile_env_allowlist] if isinstance(profile_env_allowlist, list) else None + env = build_worker_env(manifest, manifest_path, worker_id, build_dir, worker_dir, cwd, env_allowlist) + if runtime == "fixture": + fixture_case = str(profile_config.get("fixture_case") or fixture_case) + result = run_fixture_worker_runtime(manifest, worker_id, cwd, f"fixture-{fixture_case}", fixture_case) + result["runtime_profile"] = profile_name + return result + if runtime == "command": + argv = [format_runtime_value(item, context) for item in profile_config.get("argv") or []] + if not argv: + raise BuildError(f"runtime profile {profile_name} has no argv") + cwd_value = profile_config.get("cwd") or "{worktree}" + command_cwd = Path(format_runtime_value(cwd_value, context)) + if not command_cwd.is_absolute(): + command_cwd = cwd / command_cwd + if not command_cwd.exists(): + raise BuildError(f"runtime profile cwd does not exist: {command_cwd}") + stdin_text = None + stdin_rel = None + if profile_config.get("stdin_file") is not None: + stdin_value = format_runtime_value(profile_config.get("stdin_file"), context) + stdin_path = Path(stdin_value) + if not stdin_path.is_absolute(): + stdin_path = command_cwd / stdin_path + if not stdin_path.exists(): + raise BuildError(f"runtime profile stdin_file does not exist: {stdin_path}") + stdin_text = stdin_path.read_text(encoding="utf-8") + stdin_rel = rel(stdin_path) + started = time.perf_counter() + try: + proc = subprocess.run( + argv, + cwd=command_cwd, + shell=False, + env=env, + input=stdin_text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + except OSError as exc: + raise BuildError(f"runtime command launch failed: {exc}") from exc + return { + "runtime": runtime, + "runtime_profile": profile_name, + "argv": argv, + "cwd": str(command_cwd), + "stdin_file": stdin_rel, + "exit_code": proc.returncode, + "status": "passed" if proc.returncode == 0 else "failed", + "stdout": proc.stdout, + "stderr": proc.stderr, + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + } + raise BuildError(f"unknown runtime profile type for {profile_name}: {runtime}") + + env = build_worker_env(manifest, manifest_path, worker_id, build_dir, worker_dir, cwd) + if runtime.startswith("fixture"): + return run_fixture_worker_runtime(manifest, worker_id, cwd, runtime, fixture_case) + + if runtime in {"command", "local-codex", "codex"}: + if not allow_unsafe_command: + raise BuildError("raw command runtime requires --allow-unsafe-command or --runtime-profile") + command_template = (command_template or os.environ.get("CENTO_LOCAL_BUILDER", "")).strip() + if not command_template: + raise BuildError("runtime command requires --command or CENTO_LOCAL_BUILDER, e.g. `codex exec --prompt-file {prompt}`") + formatted = command_template.format( + prompt=shlex.quote(str(prompt_path)), + manifest=shlex.quote(str(manifest_path)), + build_dir=shlex.quote(str(build_dir)), + worker_dir=shlex.quote(str(worker_dir)), + worktree=shlex.quote(str(cwd)), + worker=shlex.quote(worker_id), + artifact_dir=shlex.quote(str(worker_dir)), + ) + started = time.perf_counter() + proc = subprocess.run( + formatted, + cwd=cwd, + shell=True, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + return { + "runtime": runtime, + "exit_code": proc.returncode, + "status": "passed" if proc.returncode == 0 else "failed", + "stdout": proc.stdout, + "stderr": proc.stderr, + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + } + + raise BuildError(f"unknown worker runtime: {runtime}") + + +def run_build_worker( + manifest_path: Path, + *, + worker_id: str, + runtime: str, + use_worktree: bool, + timeout: int | None, + allow_dirty_owned: bool = False, + fixture_case: str = "valid", + command_template: str | None = None, + runtime_profile_name: str | None = None, + allow_unsafe_command: bool = False, +) -> dict[str, Any]: + if not manifest_path.is_absolute(): + manifest_path = ROOT / manifest_path + manifest = read_json(manifest_path) + build_dir = build_dir_for_manifest(manifest, manifest_path) + build_dir.mkdir(parents=True, exist_ok=True) + worker = manifest_worker(manifest, worker_id) + worker_dir = worker_artifact_dir(manifest, worker_id, build_dir) + worker_dir.mkdir(parents=True, exist_ok=True) + profile_config: dict[str, Any] | None = None + if runtime_profile_name: + profile_config = runtime_profile(runtime_profile_name) + runtime = str(profile_config.get("type") or runtime) + if runtime == "fixture": + fixture_case = str(profile_config.get("fixture_case") or fixture_case) + if runtime == "command" and not use_worktree: + raise BuildError("command runtime profiles require --worktree") + effective_timeout = runtime_timeout(profile_config, timeout) + patch_path = worker_dir / "patch.diff" + bundle_path = worker_dir / "patch_bundle.json" + artifact_path = worker_dir / "worker_artifact.json" + handoff_path = worker_dir / "handoff.md" + for stale_path in (patch_path, bundle_path, artifact_path, handoff_path, worker_dir / "runtime.stdout", worker_dir / "runtime.stderr"): + if stale_path.exists(): + stale_path.unlink() + warnings: list[str] = [] + errors: list[str] = [] + worktree_path: Path | None = None + worktree_removed = False + started_at = now_iso() + + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + if allow_dirty_owned: + policies = {**policies, "allow_dirty_owned": True} + manifest = {**manifest, "policies": policies} + manifest_result = validate_manifest(manifest, allow_dirty_owned=allow_dirty_owned) + if manifest_result["status"] != "passed": + try: + failure_write_paths = worker_write_paths(manifest, worker_id) + failure_dirty_owned, failure_dirty_unrelated = dirty_paths_for(failure_write_paths) + except BuildError: + failure_dirty_owned, failure_dirty_unrelated = [], [] + patch_path.write_text("", encoding="utf-8") + (worker_dir / "runtime.stdout").write_text("", encoding="utf-8") + (worker_dir / "runtime.stderr").write_text("; ".join(manifest_result["errors"]), encoding="utf-8") + failure_artifact = { + "schema_version": SCHEMA_WORKER_ARTIFACT, + "manifest_id": manifest.get("id"), + "manifest_path": rel(manifest_path), + "worker_id": worker_id, + "worker_type": str(worker.get("type") or "local"), + "role": str(worker.get("role") or "builder"), + "runtime": runtime, + "runtime_profile": runtime_profile_name, + "fixture_case": fixture_case if runtime.startswith("fixture") else None, + "status": "failed", + "base_ref": str((manifest.get("source") or {}).get("base_ref") or "HEAD") if isinstance(manifest.get("source"), dict) else "HEAD", + "artifact_dir": rel(worker_dir), + "patch_file": rel(patch_path), + "patch_path": rel(patch_path), + "patch_bundle": None, + "handoff": rel(handoff_path), + "touched_paths": [], + "owned_paths": [], + "unowned_paths": [], + "protected_paths_touched": [], + "staged_paths": [], + "dirty_owned_paths": failure_dirty_owned, + "dirty_unrelated_paths": failure_dirty_unrelated, + "rejections": [str(item) for item in manifest_result["errors"]], + "assumptions": [], + "validation": {"status": "not_run", "reason": "manifest check failed before worker launch"}, + "risks": [str(item) for item in manifest_result["errors"]], + "warnings": [str(item) for item in manifest_result["warnings"]], + "stdout_path": rel(worker_dir / "runtime.stdout"), + "stderr_path": rel(worker_dir / "runtime.stderr"), + "duration_ms": 0, + "runtime_limits": { + "timeout_seconds": effective_timeout, + "max_changed_files": runtime_limit(profile_config, "max_changed_files"), + "max_patch_lines": runtime_limit(profile_config, "max_patch_lines"), + }, + "runtime_result": { + "status": "not_run", + "exit_code": None, + "stdout_path": rel(worker_dir / "runtime.stdout"), + "stderr_path": rel(worker_dir / "runtime.stderr"), + }, + "launch_head": "", + "worker_head": "", + "started_at": started_at, + "completed_at": now_iso(), + } + write_json(artifact_path, failure_artifact) + write_worker_handoff( + handoff_path, + status="failed", + runtime=runtime, + touched_paths=[], + errors=[str(item) for item in manifest_result["errors"]], + warnings=[str(item) for item in manifest_result["warnings"]], + ) + append_event( + build_dir, + "worker_artifact_written", + { + "worker_id": worker_id, + "runtime": runtime, + "runtime_profile": runtime_profile_name, + "status": "failed", + "artifact": rel(artifact_path), + }, + ) + raise BuildError("manifest check failed: " + "; ".join(manifest_result["errors"])) + warnings.extend([str(item) for item in manifest_result["warnings"]]) + + write_paths = worker_write_paths(manifest, worker_id) + dirty_owned, dirty_unrelated = dirty_paths_for(write_paths) + if dirty_owned and not policy_allows_dirty_owned(policies): + raise BuildError("dirty owned paths present: " + ", ".join(dirty_owned)) + + base_ref = str((manifest.get("source") or {}).get("base_ref") or "HEAD") if isinstance(manifest.get("source"), dict) else "HEAD" + current_base = git_value(["rev-parse", "HEAD"], "HEAD") + run_cwd = ROOT + append_event(build_dir, "worker_started", {"worker_id": worker_id, "runtime": runtime, "runtime_profile": runtime_profile_name}) + runtime_result: dict[str, Any] = { + "runtime": runtime, + "runtime_profile": runtime_profile_name, + "fixture_case": fixture_case if runtime.startswith("fixture") else None, + "exit_code": None, + "status": "failed", + "stdout": "", + "stderr": "", + "duration_ms": 0, + } + runtime_failed = False + skip_collection = False + launch_head = "" + worker_head = "" + try: + if use_worktree: + worktree_ref = current_base if base_ref == "HEAD" else base_ref + worktree_path, worktree_result = create_isolated_worktree(worktree_ref, f"{manifest.get('id')}-{worker_id}") + if worktree_path is None: + detail = (str(worktree_result["stderr"]) or str(worktree_result["stdout"])).strip() + raise BuildError("isolated worker worktree creation failed: " + detail) + run_cwd = worktree_path + + launch_head_result = run(["git", "rev-parse", "HEAD"], cwd=run_cwd, timeout=30) + launch_head = str(launch_head_result["stdout"]).strip() if launch_head_result["exit_code"] == 0 else "" + runtime_result = run_worker_runtime( + manifest, + manifest_path, + worker_id, + runtime, + run_cwd, + build_dir, + worker_dir, + effective_timeout, + fixture_case=fixture_case, + command_template=command_template, + profile_name=runtime_profile_name, + profile_config=profile_config, + allow_unsafe_command=allow_unsafe_command, + ) + if runtime_result.get("exit_code") != 0: + runtime_failed = True + errors.append("worker runtime failed") + except (BuildError, subprocess.TimeoutExpired) as exc: + runtime_failed = True + if use_worktree and worktree_path is None: + skip_collection = True + errors.append(str(exc)) + runtime_result["stderr"] = str(exc) + runtime_result["status"] = "failed" + try: + if skip_collection: + status_lines = [] + status_touched_paths = [] + patch_text = "" + else: + worker_head_result = run(["git", "rev-parse", "HEAD"], cwd=run_cwd, timeout=30) + worker_head = str(worker_head_result["stdout"]).strip() if worker_head_result["exit_code"] == 0 else "" + if launch_head and worker_head and worker_head != launch_head: + errors.append("worker commits are rejected") + + status_lines = git_status_lines_for(run_cwd) + status_touched_paths = status_paths(status_lines) + staged_paths = sorted( + set( + normalize_path(status_path(line)) + for line in status_lines + if line and line[0] not in {" ", "?"} + ) + ) + if staged_paths: + errors.append("worker staged files are rejected: " + ", ".join(staged_paths)) + if not skip_collection: + diff_result = run(["git", "diff", "--binary"], cwd=run_cwd, timeout=120) + if diff_result["exit_code"] != 0: + errors.append("git diff failed: " + (str(diff_result["stderr"]) or str(diff_result["stdout"])).strip()) + patch_text = "" + else: + patch_text = str(diff_result["stdout"]) + patch_path.write_text(patch_text, encoding="utf-8") + + analysis = analyze_patch(patch_path) if patch_text.strip() else {"paths": [], "path_errors": []} + patch_touched_paths = [str(path) for path in analysis.get("paths") or []] + touched_paths = sorted(set(status_touched_paths + patch_touched_paths)) + if not patch_text.strip(): + errors.append("worker produced no patch") + max_changed_files = runtime_limit(profile_config, "max_changed_files") + if max_changed_files is not None and len(touched_paths) > max_changed_files: + errors.append(f"worker touched too many files: {len(touched_paths)} > {max_changed_files}") + max_patch_lines = runtime_limit(profile_config, "max_patch_lines") + if max_patch_lines is not None and len(patch_text.splitlines()) > max_patch_lines: + errors.append(f"worker patch is too large: {len(patch_text.splitlines())} lines > {max_patch_lines}") + + protected_paths = manifest_protected_paths(manifest) + unowned_paths = [path for path in touched_paths if not path_allowed(path, write_paths)] + protected_touched = [path for path in touched_paths if path_is_protected(path, protected_paths)] + if unowned_paths: + errors.append("worker touched unowned paths: " + ", ".join(unowned_paths)) + if protected_touched: + errors.append("worker touched protected paths: " + ", ".join(protected_touched)) + if patch_text.strip(): + errors.extend(patch_policy_rejections(analysis, write_paths, protected_paths, policies)) + if patch_text.strip() and not errors: + synthesize_patch_bundle( + manifest, + patch_path, + patch_touched_paths, + build_dir, + out_path=bundle_path, + worker_id=worker_id, + summary=f"Collected from {runtime_profile_name or runtime} worker runtime.", + ) + append_event(build_dir, "patch_bundle_created", {"worker_id": worker_id, "patch_bundle": rel(bundle_path)}) + + status = "failed" if runtime_failed or errors == ["worker produced no patch"] else ("rejected" if errors else "completed") + artifact = { + "schema_version": SCHEMA_WORKER_ARTIFACT, + "manifest_id": manifest.get("id"), + "manifest_path": rel(manifest_path), + "worker_id": worker_id, + "worker_type": str(worker.get("type") or "local"), + "role": str(worker.get("role") or "builder"), + "runtime": runtime, + "runtime_profile": runtime_profile_name, + "fixture_case": fixture_case if runtime.startswith("fixture") else None, + "status": status, + "base_ref": base_ref, + "artifact_dir": rel(worker_dir), + "patch_file": rel(patch_path) if patch_path.exists() else None, + "patch_path": rel(patch_path) if patch_path.exists() else None, + "patch_bundle": rel(bundle_path) if bundle_path.exists() else None, + "handoff": rel(handoff_path), + "touched_paths": touched_paths, + "owned_paths": [path for path in touched_paths if path_allowed(path, write_paths)], + "unowned_paths": unowned_paths, + "protected_paths_touched": protected_touched, + "staged_paths": staged_paths, + "dirty_unrelated_paths": dirty_unrelated, + "rejections": errors, + "assumptions": [], + "validation": {"status": "not_run", "reason": "worker collection only; integration validates patch"}, + "risks": errors, + "warnings": warnings, + "stdout_path": rel(worker_dir / "runtime.stdout"), + "stderr_path": rel(worker_dir / "runtime.stderr"), + "duration_ms": runtime_result.get("duration_ms", 0), + "runtime_limits": { + "timeout_seconds": effective_timeout, + "max_changed_files": runtime_limit(profile_config, "max_changed_files"), + "max_patch_lines": runtime_limit(profile_config, "max_patch_lines"), + }, + "runtime_result": { + "status": runtime_result.get("status"), + "exit_code": runtime_result.get("exit_code"), + "stdout_path": rel(worker_dir / "runtime.stdout"), + "stderr_path": rel(worker_dir / "runtime.stderr"), + "argv": runtime_result.get("argv"), + "cwd": runtime_result.get("cwd"), + }, + "launch_head": launch_head, + "worker_head": worker_head, + "started_at": started_at, + "completed_at": now_iso(), + } + (worker_dir / "runtime.stdout").write_text(str(runtime_result.get("stdout") or ""), encoding="utf-8") + (worker_dir / "runtime.stderr").write_text(str(runtime_result.get("stderr") or ""), encoding="utf-8") + write_json(artifact_path, artifact) + write_worker_handoff(handoff_path, status=status, runtime=runtime, touched_paths=touched_paths, errors=errors, warnings=warnings) + append_event( + build_dir, + "worker_artifact_written", + { + "worker_id": worker_id, + "runtime": runtime, + "runtime_profile": runtime_profile_name, + "status": status, + "artifact": rel(artifact_path), + }, + ) + except Exception: + raise + finally: + remove_result = remove_isolated_worktree(worktree_path) + if remove_result is not None: + worktree_removed = remove_result["exit_code"] == 0 + if not worktree_removed: + warnings.append("worker worktree cleanup failed: " + (str(remove_result["stderr"]) or str(remove_result["stdout"])).strip()) + + result = { + "status": "accepted" if artifact_path.exists() and read_json(artifact_path).get("status") == "completed" else "rejected", + "worker_status": read_json(artifact_path).get("status") if artifact_path.exists() else "failed", + "build_id": manifest.get("id"), + "worker_id": worker_id, + "runtime": runtime, + "runtime_profile": runtime_profile_name, + "worker_dir": rel(worker_dir), + "worker_artifact": rel(artifact_path), + "patch_bundle": rel(bundle_path) if bundle_path.exists() else None, + "patch": rel(patch_path), + "handoff": rel(handoff_path), + "touched_paths": read_json(artifact_path).get("touched_paths", []) if artifact_path.exists() else [], + "worktree_removed": worktree_removed if use_worktree else None, + } + if result["status"] != "accepted": + artifact = read_json(artifact_path) if artifact_path.exists() else {} + result["errors"] = artifact.get("risks") or ["worker run rejected"] + return result + + +def write_apply_receipt(build_dir: Path, receipt: dict[str, Any]) -> Path: + latest = build_dir / "apply_receipt.json" + write_json(latest, receipt) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + write_json(build_dir / "apply" / "receipts" / f"{stamp}.json", receipt) + return latest + + +def resolved_repo_path(path: Path) -> Path: + return path if path.is_absolute() else ROOT / path + + +def same_fileish_path(left: str | None, right: Path) -> bool: + if not left: + return False + candidate = Path(left) + if not candidate.is_absolute(): + candidate = ROOT / candidate + try: + return candidate.resolve() == right.resolve() + except FileNotFoundError: + return candidate.absolute() == right.absolute() + + +def write_taskstream_evidence( + build_dir: Path, + manifest: dict[str, Any], + *, + status: str, + changed_files: list[str], + risk_overrides: list[str] | None = None, +) -> Path: + build_id = str(manifest.get("id") or build_dir.name) + worker_artifacts = sorted(rel(path) for path in (build_dir / "workers").glob("*/worker_artifact.json")) + patch_bundles = sorted(rel(path) for path in (build_dir / "workers").glob("*/patch_bundle.json")) + evidence = { + "schema_version": SCHEMA_TASKSTREAM_EVIDENCE, + "type": "cento_build_evidence", + "build_id": build_id, + "task_id": None, + "mode": manifest.get("mode"), + "manifest": rel(build_dir / "manifest.json"), + "worker_artifacts": worker_artifacts, + "patch_bundles": patch_bundles, + "integration_receipt": rel(build_dir / "integration_receipt.json") if (build_dir / "integration_receipt.json").exists() else None, + "validation_receipt": rel(build_dir / "validation_receipt.json") if (build_dir / "validation_receipt.json").exists() else None, + "apply_receipt": rel(build_dir / "apply_receipt.json") if (build_dir / "apply_receipt.json").exists() else None, + "events": rel(build_dir / "events.ndjson") if (build_dir / "events.ndjson").exists() else None, + "changed_files": changed_files, + "status": status, + "risk_overrides": risk_overrides or [], + "screenshots": [], + "written_at": now_iso(), + } + path = build_dir / "taskstream_evidence.json" + write_json(path, evidence) + append_event(build_dir, "taskstream_evidence_attached", {"status": status, "path": rel(path)}) + return path + + +def apply_build_bundle( + manifest_path: Path, + bundle_path: Path, + receipt_path: Path, + *, + allow_dirty_owned: bool = False, + allow_base_mismatch: bool = False, +) -> dict[str, Any]: + manifest_path = resolved_repo_path(manifest_path) + bundle_path = resolved_repo_path(bundle_path) + receipt_path = resolved_repo_path(receipt_path) + manifest = read_json(manifest_path) + build_dir = build_dir_for_manifest(manifest, manifest_path) + checks: list[dict[str, Any]] = [] + rejections: list[str] = [] + warnings: list[str] = [] + dirty_owned: list[str] = [] + dirty_unrelated: list[str] = [] + applied = False + validation_receipt: dict[str, Any] | None = None + + def reject(name: str, detail: str) -> None: + add_check(checks, name, "failed", detail) + rejections.append(detail) + + try: + bundle = read_json(bundle_path) + add_check(checks, "patch_bundle_loaded", "passed", rel(bundle_path)) + except BuildError as exc: + bundle = {} + reject("patch_bundle_loaded", str(exc)) + + try: + integration = read_json(receipt_path) + add_check(checks, "integration_receipt_loaded", "passed", rel(receipt_path)) + except BuildError as exc: + integration = {} + reject("integration_receipt_loaded", str(exc)) + + if integration.get("schema_version") != SCHEMA_INTEGRATION_RECEIPT: + reject("integration_receipt_schema", "integration receipt schema mismatch") + elif integration.get("status") != "accepted": + reject("integration_receipt_status", "integration receipt is not accepted") + else: + add_check(checks, "integration_receipt_status", "passed", rel(receipt_path)) + + if integration.get("manifest_id") != manifest.get("id"): + reject("manifest_match", "manifest id mismatch") + else: + add_check(checks, "manifest_match", "passed") + + if bundle.get("manifest_id") != manifest.get("id"): + reject("bundle_manifest_match", "bundle manifest id mismatch") + else: + add_check(checks, "bundle_manifest_match", "passed") + + bundle_id = str(bundle.get("id") or "") + receipt_bundle_id = str(integration.get("patch_bundle_id") or integration.get("bundle_id") or "") + if bundle_id and receipt_bundle_id and bundle_id != receipt_bundle_id: + reject("bundle_id_match", f"bundle id mismatch: bundle={bundle_id} receipt={receipt_bundle_id}") + elif bundle_id: + add_check(checks, "bundle_id_match", "passed", bundle_id) + else: + add_check(checks, "bundle_id_match", "warning", "bundle id missing") + + if not same_fileish_path(str(integration.get("patch_bundle") or ""), bundle_path): + reject("bundle_receipt_match", "bundle path does not match accepted integration receipt") + else: + add_check(checks, "bundle_receipt_match", "passed", rel(bundle_path)) + + patch_path: Path | None = None + analysis: dict[str, Any] | None = None + touched_paths: list[str] = [] + write_paths = manifest_write_paths(manifest) + protected_paths = manifest_protected_paths(manifest) + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + if allow_dirty_owned: + policies = {**policies, "allow_dirty_owned": True} + try: + patch_path = resolve_bundle_patch_path(bundle, bundle_path) + analysis = analyze_patch(patch_path) + touched_paths = [str(path) for path in analysis.get("paths") or []] + add_check(checks, "patch_loaded", "passed", rel(patch_path)) + except BuildError as exc: + reject("patch_loaded", str(exc)) + + if analysis is not None: + bundle_result = validate_patch_bundle(bundle, manifest, bundle_path, analysis) + add_rejections(checks, rejections, "patch_bundle_contract", [str(error) for error in bundle_result.get("errors") or []]) + warnings.extend([str(warning) for warning in bundle_result.get("warnings") or []]) + + try: + dirty_owned, dirty_unrelated = dirty_paths_for(write_paths) + except BuildError as exc: + warnings.append(str(exc)) + add_check(checks, "dirty_owned_check", "warning", str(exc)) + else: + if dirty_unrelated: + add_check(checks, "dirty_unrelated_check", "passed", f"{len(dirty_unrelated)} unrelated dirty path(s) preserved") + else: + add_check(checks, "dirty_unrelated_check", "passed") + if dirty_owned and not policy_allows_dirty_owned(policies): + reject("dirty_owned_check", "dirty owned paths present: " + ", ".join(dirty_owned)) + elif dirty_owned: + add_check(checks, "dirty_owned_check", "warning", ", ".join(dirty_owned)) + warnings.append("dirty owned paths present (allow_dirty_owned): " + ", ".join(dirty_owned)) + else: + add_check(checks, "dirty_owned_check", "passed") + + source = manifest.get("source") if isinstance(manifest.get("source"), dict) else {} + expected_base = str(source.get("base_ref") or "HEAD") + current_base = git_value(["rev-parse", "HEAD"], "HEAD") + base_match = allow_base_mismatch or base_ref_matches(expected_base, current_base, allow_head=fixture_or_dev_path(manifest_path)) + if base_match: + add_check(checks, "base_ref_check", "passed" if not allow_base_mismatch else "warning", f"manifest={expected_base} current={current_base}") + else: + reject("base_ref_check", f"base ref mismatch: manifest={expected_base} current={current_base}") + + if not rejections and patch_path is not None: + check_result = run(["git", "apply", "--check", str(patch_path)], cwd=ROOT, timeout=120) + if check_result["exit_code"] != 0: + reject("git_apply_check", (str(check_result["stderr"]) or str(check_result["stdout"])).strip() or "git apply --check failed") + else: + add_check(checks, "git_apply_check", "passed") + apply_result = run(["git", "apply", str(patch_path)], cwd=ROOT, timeout=120) + if apply_result["exit_code"] != 0: + reject("git_apply", (str(apply_result["stderr"]) or str(apply_result["stdout"])).strip() or "git apply failed") + else: + applied = True + add_check(checks, "git_apply", "passed") + validation_receipt = run_validation_receipt(manifest, build_dir, cwd=ROOT) + add_check(checks, "validation_receipt", validation_receipt["status"], rel(build_dir / "validation_receipt.json")) + elif rejections: + validation_receipt = run_validation_receipt(manifest, build_dir, skipped=True, reason="apply pre-checks failed") + add_check(checks, "validation_receipt", "skipped", rel(build_dir / "validation_receipt.json")) + + status = "applied" if applied and not any(check["name"] == "git_apply" and check["status"] == "failed" for check in checks) else ("failed" if any(check["name"].endswith("_loaded") and check["status"] == "failed" for check in checks) else "rejected") + risk_overrides = [] + if allow_dirty_owned: + risk_overrides.append("allow_dirty_owned") + if allow_base_mismatch: + risk_overrides.append("allow_base_mismatch") + receipt = { + "schema_version": SCHEMA_APPLY_RECEIPT, + "manifest_id": manifest.get("id"), + "bundle_id": str(bundle.get("id") or ""), + "status": status, + "mode": manifest.get("mode"), + "patch_bundle": rel(bundle_path), + "patch_path": rel(patch_path) if patch_path else None, + "integration_receipt": rel(receipt_path), + "touched_paths": touched_paths, + "changed_paths": touched_paths, + "checks": checks, + "applied": applied, + "rejections": rejections, + "warnings": warnings, + "risk_overrides": risk_overrides, + "dirty_owned_paths": dirty_owned, + "dirty_unrelated_paths": dirty_unrelated, + "base_ref_manifest": expected_base, + "base_ref_current": current_base, + "base_ref_match": base_match, + "validation_receipt": rel(build_dir / "validation_receipt.json") if validation_receipt else None, + "written_at": now_iso(), + } + apply_receipt_path = write_apply_receipt(build_dir, receipt) + evidence_status = "review" if status == "applied" and (validation_receipt or {}).get("status") == "passed" else "blocked" + write_taskstream_evidence(build_dir, manifest, status=evidence_status, changed_files=touched_paths, risk_overrides=risk_overrides) + append_event( + build_dir, + "patch_applied" if status == "applied" else "patch_apply_rejected", + {"status": status, "apply_receipt": rel(apply_receipt_path), "patch_bundle": rel(bundle_path), "rejections": rejections}, + ) + return receipt + + +def command_init(args: argparse.Namespace) -> int: + manifest = create_manifest(args) + build_dir = BUILD_ROOT / str(manifest["id"]) + if build_dir.exists() and not args.force: + raise BuildError(f"build already exists: {rel(build_dir)}; use --force to overwrite manifest and prompt") + build_dir.mkdir(parents=True, exist_ok=True) + write_json(build_dir / "manifest.json", manifest) + (build_dir / "builder.prompt.md").write_text(render_builder_prompt(manifest), encoding="utf-8") + append_event(build_dir, "build_manifest_created", {"manifest_id": manifest["id"]}) + append_event(build_dir, "builder_prompt_created", {"path": rel(build_dir / "builder.prompt.md")}) + result = {"build_id": manifest["id"], "build_dir": rel(build_dir), "manifest": rel(build_dir / "manifest.json"), "prompt": rel(build_dir / "builder.prompt.md")} + print(json.dumps(result, indent=2) if args.json else rel(build_dir / "manifest.json")) + return 0 + + +def command_check(args: argparse.Namespace) -> int: + manifest_path = Path(args.manifest) + manifest = read_json(manifest_path) + result = validate_manifest(manifest) + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"manifest check: {result['status']}") + for warning in result["warnings"]: + print(f"warning: {warning}", file=sys.stderr) + for error in result["errors"]: + print(f"error: {error}", file=sys.stderr) + return 0 if result["status"] == "passed" else 1 + + +def command_prompt(args: argparse.Namespace) -> int: + manifest_path = Path(args.manifest) + manifest = read_json(manifest_path) + text = render_builder_prompt(manifest) + if args.write or args.out: + build_dir = build_dir_for_manifest(manifest, manifest_path) + out_path = Path(args.out) if args.out else build_dir / "builder.prompt.md" + if not out_path.is_absolute(): + out_path = ROOT / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(text, encoding="utf-8") + append_event(build_dir, "builder_prompt_created", {"path": rel(out_path)}) + print(rel(out_path)) + else: + print(text, end="") + return 0 + + +def command_integrate(args: argparse.Namespace) -> int: + if args.apply: + raise BuildError("cento build integrate v1 supports dry-run only; omit --apply") + + manifest_path = Path(args.manifest) + checks: list[dict[str, Any]] = [] + rejections: list[str] = [] + warnings: list[str] = [] + touched_paths: list[str] = [] + manifest: dict[str, Any] = {"id": manifest_path.stem, "mode": "unknown"} + build_dir = BUILD_ROOT / manifest_path.stem + bundle: dict[str, Any] | None = None + patch_bundle_path: Path | None = None + patch_path: Path | None = None + analysis: dict[str, Any] | None = None + validation_receipt: dict[str, Any] | None = None + worker_base_ref: str | None = None + dirty_owned: list[str] = [] + dirty_unrelated: list[str] = [] + worktree_path: Path | None = None + worktree_removed = False + + try: + manifest = read_json(manifest_path) + build_dir = build_dir_for_manifest(manifest, manifest_path) + build_dir.mkdir(parents=True, exist_ok=True) + add_check(checks, "manifest_loaded", "passed") + except BuildError as exc: + add_check(checks, "manifest_loaded", "failed", str(exc)) + rejections.append(str(exc)) + + if not rejections: + if args.allow_dirty_owned: + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + policies = {**policies, "allow_dirty_owned": True} + manifest = {**manifest, "policies": policies} + manifest_result = validate_manifest(manifest) + for warning in manifest_result["warnings"]: + warnings.append(warning) + if manifest_result["status"] == "passed": + add_check(checks, "manifest_shape", "passed") + else: + add_check(checks, "manifest_shape", "failed", "; ".join(manifest_result["errors"])) + rejections.extend(manifest_result["errors"]) + + bundle_arg = getattr(args, "bundle", None) or getattr(args, "patch_bundle", None) + raw_patch_arg = getattr(args, "patch", None) + if bundle_arg: + patch_bundle_path = Path(bundle_arg) + if not patch_bundle_path.is_absolute(): + patch_bundle_path = ROOT / patch_bundle_path + try: + bundle = read_json(patch_bundle_path) + add_check(checks, "patch_bundle_loaded", "passed", rel(patch_bundle_path)) + patch_path = resolve_bundle_patch_path(bundle, patch_bundle_path) + worker_base_ref = str(bundle.get("base_ref") or "") or None + except BuildError as exc: + add_check(checks, "patch_bundle_loaded", "failed", str(exc)) + rejections.append(str(exc)) + elif raw_patch_arg: + patch_path = Path(raw_patch_arg) + if not patch_path.is_absolute(): + patch_path = ROOT / patch_path + if not args.dev_raw_patch: + add_check(checks, "raw_patch_policy", "failed", "use `cento build bundle synthesize` and integrate with --bundle") + rejections.append("raw patch integration requires --dev-raw-patch or prior bundle synthesis") + else: + add_check(checks, "raw_patch_policy", "warning", "dev raw patch integration") + warnings.append("raw patch integrated in dev mode") + else: + add_check(checks, "patch_source", "failed", "provide --bundle patch_bundle.json") + rejections.append("provide --bundle patch_bundle.json") + + if patch_path is not None: + if not patch_path.exists(): + add_check(checks, "patch_loaded", "failed", f"patch file not found: {patch_path}") + rejections.append(f"patch file not found: {patch_path}") + else: + add_check(checks, "patch_loaded", "passed", rel(patch_path)) + analysis = analyze_patch(patch_path) + touched_paths = [str(path) for path in analysis.get("paths") or []] + if analysis.get("path_errors"): + add_check(checks, "patch_path_parse", "failed", "; ".join([str(item) for item in analysis["path_errors"]])) + else: + add_check(checks, "patch_path_parse", "passed", ", ".join(touched_paths) if touched_paths else "no paths") + + write_paths = manifest_write_paths(manifest) if manifest.get("scope") else [] + protected_paths = manifest_protected_paths(manifest) if manifest.get("scope") else DEFAULT_PROTECTED_PATHS + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + + if touched_paths: + unowned_paths = [path for path in touched_paths if not path_allowed(path, write_paths)] + protected_touched = [path for path in touched_paths if path_is_protected(path, protected_paths)] + if unowned_paths: + add_check(checks, "owned_path_check", "failed", ", ".join(unowned_paths)) + rejections.append("unowned paths touched: " + ", ".join(unowned_paths)) + else: + add_check(checks, "owned_path_check", "passed") + if protected_touched: + add_check(checks, "protected_path_check", "failed", ", ".join(protected_touched)) + rejections.append("protected paths touched: " + ", ".join(protected_touched)) + else: + add_check(checks, "protected_path_check", "passed") + elif patch_path is not None and patch_path.exists(): + add_check(checks, "owned_path_check", "failed", "patch has no touched paths") + rejections.append("patch has no touched paths") + + if analysis is not None: + add_rejections(checks, rejections, "hostile_patch_check", patch_policy_rejections(analysis, write_paths, protected_paths, policies)) + + if bundle is not None and patch_bundle_path is not None: + try: + bundle_result = validate_patch_bundle(bundle, manifest, patch_bundle_path, analysis) + except BuildError as exc: + bundle_result = {"status": "failed", "errors": [str(exc)], "warnings": []} + warnings.extend([str(warning) for warning in bundle_result.get("warnings") or []]) + add_rejections(checks, rejections, "patch_bundle_contract", [str(error) for error in bundle_result.get("errors") or []]) + elif args.dev_raw_patch and patch_path is not None and analysis is not None and not rejections: + patch_bundle_path = synthesize_patch_bundle( + manifest, + patch_path, + touched_paths, + build_dir, + summary="Synthesized during explicit dev raw-patch integration.", + ) + add_check(checks, "patch_bundle_contract", "warning", f"synthesized {rel(patch_bundle_path)}") + + if write_paths: + try: + dirty_owned, dirty_unrelated = dirty_paths_for(write_paths) + except BuildError as exc: + add_check(checks, "dirty_owned_check", "warning", str(exc)) + warnings.append(str(exc)) + else: + if dirty_unrelated: + add_check(checks, "dirty_unrelated_check", "passed", f"{len(dirty_unrelated)} unrelated dirty path(s) preserved") + else: + add_check(checks, "dirty_unrelated_check", "passed") + if dirty_owned and not policy_allows_dirty_owned(policies): + add_check(checks, "dirty_owned_check", "failed", ", ".join(dirty_owned)) + message = "dirty owned paths present: " + ", ".join(dirty_owned) + if message not in rejections: + rejections.append(message) + elif dirty_owned: + add_check(checks, "dirty_owned_check", "warning", ", ".join(dirty_owned)) + warnings.append("dirty owned paths present (allow_dirty_owned): " + ", ".join(dirty_owned)) + else: + add_check(checks, "dirty_owned_check", "passed") + + if patch_path is not None: + worker_artifact_path = patch_path.parent / "worker_artifact.json" + try: + worker_artifact = load_optional_json(worker_artifact_path) + except BuildError as exc: + worker_artifact = None + add_check(checks, "worker_artifact_loaded", "failed", str(exc)) + rejections.append(str(exc)) + if worker_artifact: + add_check(checks, "worker_artifact_loaded", "passed", rel(worker_artifact_path)) + append_event(build_dir, "worker_artifact_received", {"path": rel(worker_artifact_path)}) + worker_result = validate_worker_artifact( + worker_artifact, + manifest, + allow_head_base=args.dev_raw_patch or fixture_or_dev_path(manifest_path), + ) + worker_base_ref = str(worker_artifact.get("base_ref") or worker_base_ref or "") or None + add_rejections(checks, rejections, "worker_artifact_contract", [str(error) for error in worker_result.get("errors") or []]) + else: + add_check(checks, "worker_artifact_loaded", "warning", "worker_artifact.json not found next to patch") + + source = manifest.get("source") if isinstance(manifest.get("source"), dict) else {} + expected_base = str(source.get("base_ref") or "HEAD") + current_base = git_value(["rev-parse", "HEAD"], "HEAD") + allow_head_manifest = args.dev_raw_patch or fixture_or_dev_path(manifest_path) + base_match = args.allow_base_mismatch or base_ref_matches(expected_base, current_base, allow_head=allow_head_manifest) + if not base_match: + add_check(checks, "base_ref_check", "failed", f"manifest={expected_base} current={current_base}") + rejections.append("base ref mismatch") + else: + status = "warning" if args.allow_base_mismatch or expected_base == "HEAD" else "passed" + add_check(checks, "base_ref_check", status, f"manifest={expected_base} current={current_base}") + + if worker_base_ref: + allow_head_worker = args.dev_raw_patch or fixture_or_dev_path(manifest_path) or fixture_or_dev_path(patch_bundle_path) + worker_base_match = base_ref_matches(worker_base_ref, expected_base, allow_head=allow_head_worker) + if not worker_base_match: + add_check(checks, "worker_base_ref_check", "failed", f"worker={worker_base_ref} manifest={expected_base}") + rejections.append("worker base ref mismatch") + else: + add_check(checks, "worker_base_ref_check", "passed", f"worker={worker_base_ref} manifest={expected_base}") + + integration_mode = "isolated_worktree" if args.worktree else "current_worktree_check" + validation_cwd = ROOT + if not rejections and patch_path is not None: + if args.worktree: + worktree_base = current_base if expected_base == "HEAD" else expected_base + worktree_path, worktree_result = create_isolated_worktree(worktree_base, str(manifest.get("id") or "build")) + if worktree_path is None: + detail = (str(worktree_result["stderr"]) or str(worktree_result["stdout"])).strip() + add_check(checks, "worktree_create", "failed", detail) + rejections.append("isolated worktree creation failed") + else: + add_check(checks, "worktree_create", "passed", rel(worktree_path)) + apply_check = run(["git", "apply", "--check", str(patch_path)], cwd=worktree_path, timeout=120) + if apply_check["exit_code"] != 0: + detail = (str(apply_check["stderr"]) or str(apply_check["stdout"])).strip() + add_check(checks, "git_apply_check", "failed", detail) + rejections.append("git apply --check failed") + else: + add_check(checks, "git_apply_check", "passed") + apply_result = run(["git", "apply", str(patch_path)], cwd=worktree_path, timeout=120) + if apply_result["exit_code"] != 0: + detail = (str(apply_result["stderr"]) or str(apply_result["stdout"])).strip() + add_check(checks, "worktree_patch_apply", "failed", detail) + rejections.append("isolated worktree patch apply failed") + else: + add_check(checks, "worktree_patch_apply", "passed") + validation_cwd = worktree_path + else: + apply_result = run(["git", "apply", "--check", str(patch_path)], timeout=120) + if apply_result["exit_code"] == 0: + add_check(checks, "git_apply_check", "passed") + else: + detail = (str(apply_result["stderr"]) or str(apply_result["stdout"])).strip() + add_check(checks, "git_apply_check", "failed", detail) + rejections.append("git apply --check failed") + else: + add_check(checks, "git_apply_check", "skipped", "pre-checks failed") + + if not rejections: + validation_receipt = run_validation_receipt(manifest, build_dir, cwd=validation_cwd) + if validation_receipt["status"] == "passed": + add_check(checks, "validation_receipt", "passed", rel(build_dir / "validation_receipt.json")) + else: + add_check(checks, "validation_receipt", "failed", rel(build_dir / "validation_receipt.json")) + rejections.append("validation failed") + else: + validation_receipt = run_validation_receipt(manifest, build_dir, skipped=True, reason="integration pre-checks failed") + add_check(checks, "validation_receipt", "skipped", rel(build_dir / "validation_receipt.json")) + + remove_result = remove_isolated_worktree(worktree_path) + if remove_result is not None: + worktree_removed = remove_result["exit_code"] == 0 + if not worktree_removed: + warnings.append("isolated worktree cleanup failed: " + (str(remove_result["stderr"]) or str(remove_result["stdout"])).strip()) + + status = "accepted" if not rejections else "rejected" + risk_overrides = [] + if getattr(args, "allow_dirty_owned", False): + risk_overrides.append("allow_dirty_owned") + if getattr(args, "allow_base_mismatch", False): + risk_overrides.append("allow_base_mismatch") + if getattr(args, "dev_raw_patch", False): + risk_overrides.append("dev_raw_patch") + receipt = { + "schema_version": SCHEMA_INTEGRATION_RECEIPT, + "manifest_id": manifest.get("id"), + "status": status, + "mode": manifest.get("mode"), + "integration_mode": integration_mode, + "patch_bundle": rel(patch_bundle_path) if patch_bundle_path else None, + "patch_bundle_id": str(bundle.get("id") or "") if bundle else None, + "patch_path": rel(patch_path) if patch_path else None, + "touched_paths": touched_paths, + "checks": checks, + "applied": False, + "dry_run": True, + "rejections": rejections, + "warnings": warnings, + "risk_overrides": risk_overrides, + "dirty_owned_paths": dirty_owned, + "dirty_unrelated_paths": dirty_unrelated, + "base_ref_manifest": expected_base, + "base_ref_worker": worker_base_ref, + "base_ref_current": current_base, + "base_ref_match": base_match and (not worker_base_ref or base_ref_matches(worker_base_ref, expected_base, allow_head=args.dev_raw_patch or fixture_or_dev_path(manifest_path))), + "worktree_path": rel(worktree_path) if worktree_path else None, + "worktree_removed": worktree_removed if worktree_path else None, + "validation_receipt": rel(build_dir / "validation_receipt.json") if validation_receipt else None, + "written_at": now_iso(), + } + receipt_path = write_integration_receipt(build_dir, receipt) + event_name = "integration_dry_run_passed" if status == "accepted" else "integration_dry_run_rejected" + append_event(build_dir, event_name, {"status": status, "patch_path": rel(patch_path) if patch_path else None, "rejections": rejections}) + append_event(build_dir, "integration_dry_run_completed", {"status": status, "patch_path": rel(patch_path) if patch_path else None, "rejections": rejections}) + append_event(build_dir, "build_completed", {"status": status}) + print(rel(receipt_path)) + if status != "accepted": + for rejection in rejections: + print(f"rejected: {rejection}", file=sys.stderr) + return 0 if status == "accepted" else 1 + + +def command_worker_run(args: argparse.Namespace) -> int: + manifest_path = Path(args.manifest) + try: + result = run_build_worker( + manifest_path, + worker_id=args.worker, + runtime=args.runtime, + use_worktree=args.worktree, + timeout=args.timeout, + allow_dirty_owned=args.allow_dirty_owned, + fixture_case=args.fixture_case, + command_template=args.command, + runtime_profile_name=args.runtime_profile, + allow_unsafe_command=args.allow_unsafe_command, + ) + except BuildError as exc: + print(f"cento build worker run: {exc}", file=sys.stderr) + return 1 + if args.json: + print(json.dumps(result, indent=2)) + else: + print(result["worker_artifact"]) + if result.get("patch_bundle"): + print(result["patch_bundle"]) + return 0 if result["status"] == "accepted" else 1 + + +def command_apply(args: argparse.Namespace) -> int: + try: + receipt = apply_build_bundle( + Path(args.manifest), + Path(args.bundle), + Path(args.from_receipt), + allow_dirty_owned=args.allow_dirty_owned, + allow_base_mismatch=args.allow_base_mismatch, + ) + except BuildError as exc: + print(f"cento build apply: {exc}", file=sys.stderr) + return 1 + receipt_path = build_dir_for_manifest(read_json(resolved_repo_path(Path(args.manifest))), resolved_repo_path(Path(args.manifest))) / "apply_receipt.json" + if args.json: + print(json.dumps({"status": receipt["status"], "apply_receipt": rel(receipt_path), "applied": receipt["applied"]}, indent=2)) + else: + print(rel(receipt_path)) + for rejection in receipt.get("rejections") or []: + print(f"rejected: {rejection}", file=sys.stderr) + return 0 if receipt["status"] == "applied" else 1 + + +def command_artifact_check(args: argparse.Namespace) -> int: + artifact_path = Path(args.artifact) + if not artifact_path.is_absolute(): + artifact_path = ROOT / artifact_path + + try: + artifact = read_json(artifact_path) + except BuildError as exc: + print(f"failed: {exc}", file=sys.stderr) + return 1 + + manifest: dict[str, Any] | None = None + errors: list[str] = [] + warnings: list[str] = [] + manifest_arg = args.manifest or artifact.get("manifest_path") + manifest_path: Path | None = None + if manifest_arg: + manifest_path = Path(str(manifest_arg)) + if not manifest_path.is_absolute(): + manifest_path = ROOT / manifest_path + try: + manifest = read_json(manifest_path) + except BuildError as exc: + errors.append(f"manifest load failed: {exc}") + manifest = None + else: + warnings.append("no manifest provided; ownership checks are limited") + + if manifest is not None: + result = validate_worker_artifact( + artifact, + manifest, + allow_head_base=fixture_or_dev_path(manifest_path) or fixture_or_dev_path(artifact_path), + ) + else: + result = validate_worker_artifact(artifact, None) + errors.extend([str(error) for error in result["errors"]]) + warnings.extend([str(warning) for warning in result["warnings"]]) + unowned = [str(path) for path in artifact.get("unowned_paths") or []] + if unowned: + errors.append("unowned paths in artifact: " + ", ".join(unowned)) + + status = "passed" if not errors else "failed" + result = { + "status": status, + "artifact": rel(artifact_path), + "manifest": rel(manifest_path) if manifest_path else None, + "errors": errors, + "warnings": warnings, + } + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"artifact check: {status}") + for err in errors: + print(f" error: {err}", file=sys.stderr) + for warn in warnings: + print(f" warning: {warn}") + return 0 if not errors else 1 + + +def command_bundle_synthesize(args: argparse.Namespace) -> int: + manifest_path = Path(args.manifest) + if not manifest_path.is_absolute(): + manifest_path = ROOT / manifest_path + patch_path = Path(args.patch) + if not patch_path.is_absolute(): + patch_path = ROOT / patch_path + + try: + manifest = read_json(manifest_path) + except BuildError as exc: + print(f"cento build bundle synthesize: {exc}", file=sys.stderr) + return 1 + + try: + analysis = analyze_patch(patch_path) + except BuildError as exc: + print(f"cento build bundle synthesize: {exc}", file=sys.stderr) + return 1 + touched_paths = [str(path) for path in analysis.get("paths") or []] + write_paths = manifest_write_paths(manifest) + protected_paths = manifest_protected_paths(manifest) + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + errors = [] + errors.extend(patch_policy_rejections(analysis, write_paths, protected_paths, policies)) + unowned_paths = [path for path in touched_paths if not path_allowed(path, write_paths)] + protected_touched = [path for path in touched_paths if path_is_protected(path, protected_paths)] + if unowned_paths: + errors.append("patch touches unowned paths: " + ", ".join(unowned_paths)) + if protected_touched: + errors.append("patch touches protected paths: " + ", ".join(protected_touched)) + if errors: + for error in errors: + print(f"cento build bundle synthesize: {error}", file=sys.stderr) + return 1 + + build_dir = build_dir_for_manifest(manifest, manifest_path) + out_path = Path(args.out) if args.out else None + if out_path is not None and not out_path.is_absolute(): + out_path = ROOT / out_path + bundle_path = synthesize_patch_bundle(manifest, patch_path, touched_paths, build_dir, out_path=out_path) + if args.json: + print(json.dumps({"bundle": rel(bundle_path), "touched_paths": touched_paths}, indent=2)) + else: + print(rel(bundle_path)) + return 0 + + +def command_receipt(args: argparse.Namespace) -> int: + target = Path(args.build) + if not target.is_absolute(): + target = ROOT / target + if target.is_file(): + manifest = read_json(target) + build_dir = build_dir_for_manifest(manifest, target) + else: + build_dir = target + manifest_path = build_dir / "manifest.json" + integration_path = build_dir / "integration_receipt.json" + validation_path = build_dir / "validation_receipt.json" + apply_path = build_dir / "apply_receipt.json" + evidence_path = build_dir / "taskstream_evidence.json" + manifest = read_json(manifest_path) if manifest_path.exists() else {} + integration = read_json(integration_path) if integration_path.exists() else {} + validation = read_json(validation_path) if validation_path.exists() else {} + apply_receipt = read_json(apply_path) if apply_path.exists() else {} + evidence = read_json(evidence_path) if evidence_path.exists() else {} + payload = { + "build_id": manifest.get("id") or build_dir.name, + "build_dir": rel(build_dir), + "manifest": rel(manifest_path) if manifest_path.exists() else None, + "integration_receipt": rel(integration_path) if integration_path.exists() else None, + "validation_receipt": rel(validation_path) if validation_path.exists() else None, + "apply_receipt": rel(apply_path) if apply_path.exists() else None, + "taskstream_evidence": rel(evidence_path) if evidence_path.exists() else None, + "status": integration.get("status", "pending"), + "integration": integration, + "validation": validation, + "apply": apply_receipt, + "evidence": evidence, + } + if args.json: + print(json.dumps(payload, indent=2)) + else: + print(f"build: {payload['build_id']}") + print(f"status: {payload['status']}") + if payload["integration_receipt"]: + print(f"integration_receipt: {payload['integration_receipt']}") + if payload["validation_receipt"]: + print(f"validation_receipt: {payload['validation_receipt']}") + if payload["apply_receipt"]: + print(f"apply_receipt: {payload['apply_receipt']}") + if payload["taskstream_evidence"]: + print(f"taskstream_evidence: {payload['taskstream_evidence']}") + for rejection in integration.get("rejections") or []: + print(f"rejection: {rejection}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + modes = sorted(load_modes()) + parser = argparse.ArgumentParser( + prog="cento build", + description="Create and dry-run integrate manifest-owned local build packages.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + init = sub.add_parser("init", help="Create a build manifest and Builder prompt.") + init.add_argument("--task", required=True, help="Operator task title.") + init.add_argument("--description", help="Longer task description.") + init.add_argument("--mode", default="fast", choices=modes, help="Execution mode to copy into the manifest.") + init.add_argument("--write", action="append", required=True, help="Owned writable path. Repeatable.") + init.add_argument("--read", action="append", default=[], help="Read-only path. Repeatable.") + init.add_argument("--route", action="append", default=[], help="Target route or URL. Repeatable.") + init.add_argument("--protect", action="append", default=[], help="Protected path glob. Repeatable.") + init.add_argument("--validation", help="Override validation tier.") + init.add_argument("--id", help="Deterministic build id.") + init.add_argument("--allow-dirty-owned", action="store_true", help="Record that dirty owned paths are allowed.") + init.add_argument("--force", action="store_true", help="Overwrite existing manifest and prompt for the build id.") + init.add_argument("--json", action="store_true", help="Print JSON result.") + init.set_defaults(func=command_init) + + check = sub.add_parser("check", help="Validate manifest shape and local path policy.") + check.add_argument("manifest", help="Manifest JSON path.") + check.add_argument("--json", action="store_true", help="Print JSON result.") + check.set_defaults(func=command_check) + + prompt = sub.add_parser("prompt", help="Print or rewrite the Builder prompt for a manifest.") + prompt.add_argument("manifest", help="Manifest JSON path.") + prompt.add_argument("--write", action="store_true", help="Write builder.prompt.md beside the build manifest.") + prompt.add_argument("--out", help="Write prompt to a specific path.") + prompt.set_defaults(func=command_prompt) + + integrate = sub.add_parser("integrate", help="Dry-run integrate a patch bundle against manifest-owned paths.") + integrate.add_argument("manifest", help="Manifest JSON path.") + integrate.add_argument("--bundle", help="patch_bundle.json path.") + integrate.add_argument("--patch-bundle", help="Backward-compatible alias for --bundle.") + integrate.add_argument("--patch", help="Raw patch diff path; rejected unless --dev-raw-patch is set.") + integrate.add_argument("--dry-run", action="store_true", help="Document intent; v1 is always dry-run.") + integrate.add_argument("--worktree", action="store_true", help="Run apply and validation in an isolated clean git worktree.") + integrate.add_argument("--apply", action="store_true", help="Reserved for a future non-dry-run integrator.") + integrate.add_argument("--allow-base-mismatch", action="store_true", help="Do not reject when manifest base_ref differs from HEAD.") + integrate.add_argument("--allow-dirty-owned", action="store_true", help="Allow dirty owned paths; recorded as a risk override in the receipt.") + integrate.add_argument("--dev-raw-patch", action="store_true", help="Local fixture/dev escape hatch for raw --patch integration.") + integrate.set_defaults(func=command_integrate) + + worker = sub.add_parser("worker", help="Run or inspect a local build worker.") + worker_sub = worker.add_subparsers(dest="worker_command", required=True) + worker_run = worker_sub.add_parser("run", help="Run one local worker and collect patch artifacts.") + worker_run.add_argument("manifest", help="Manifest JSON path.") + worker_run.add_argument("--worker", default="builder_1", help="Worker id from the manifest.") + worker_run.add_argument("--runtime", default="fixture", help="Worker runtime adapter, e.g. fixture or command.") + worker_run.add_argument("--runtime-profile", help="Named runtime profile from .cento/runtimes.yaml.") + worker_run.add_argument("--fixture-case", default="valid", choices=["valid", "unowned", "protected", "delete", "lockfile", "binary"], help="Deterministic fixture case for --runtime fixture.") + worker_run.add_argument("--command", help="Command template for --runtime command; supports {manifest}, {prompt}, {build_dir}, {worker_dir}, {worktree}, {worker}, {artifact_dir}.") + worker_run.add_argument("--worktree", action="store_true", help="Run the worker in an isolated git worktree.") + worker_run.add_argument("--timeout", type=int, default=None, help="Worker timeout in seconds; runtime profiles can provide the default.") + worker_run.add_argument("--allow-dirty-owned", action="store_true", help="Allow dirty owned paths before worker launch.") + worker_run.add_argument("--allow-unsafe-command", action="store_true", help="Allow raw shell command runtime without a named profile.") + worker_run.add_argument("--json", action="store_true", help="Print JSON result.") + worker_run.set_defaults(func=command_worker_run) + + apply_cmd = sub.add_parser("apply", help="Apply an accepted patch bundle to the operator worktree.") + apply_cmd.add_argument("manifest", help="Manifest JSON path.") + apply_cmd.add_argument("--bundle", required=True, help="patch_bundle.json path.") + apply_cmd.add_argument("--from-receipt", required=True, help="Accepted integration_receipt.json path.") + apply_cmd.add_argument("--allow-dirty-owned", action="store_true", help="Allow dirty owned paths; recorded as a risk override.") + apply_cmd.add_argument("--allow-base-mismatch", action="store_true", help="Apply even if manifest base_ref differs from HEAD.") + apply_cmd.add_argument("--json", action="store_true", help="Print JSON result.") + apply_cmd.set_defaults(func=command_apply) + + artifact = sub.add_parser("artifact", help="Check or inspect a worker artifact.") + artifact_sub = artifact.add_subparsers(dest="artifact_command", required=True) + artifact_check = artifact_sub.add_parser("check", help="Validate a worker_artifact.json against schema and optional manifest.") + artifact_check.add_argument("artifact", help="worker_artifact.json path.") + artifact_check.add_argument("--manifest", help="Optional manifest JSON path to cross-check ownership and base ref.") + artifact_check.add_argument("--json", action="store_true", help="Print JSON result.") + artifact_check.set_defaults(func=command_artifact_check) + + bundle = sub.add_parser("bundle", help="Synthesize or inspect patch bundles.") + bundle_sub = bundle.add_subparsers(dest="bundle_command", required=True) + bundle_synth = bundle_sub.add_parser("synthesize", help="Synthesize a patch_bundle.json from a manifest and raw patch file.") + bundle_synth.add_argument("--manifest", required=True, help="Manifest JSON path.") + bundle_synth.add_argument("--patch", required=True, help="Patch diff path.") + bundle_synth.add_argument("--out", help="Write the patch bundle to a specific path.") + bundle_synth.add_argument("--json", action="store_true", help="Print JSON result.") + bundle_synth.set_defaults(func=command_bundle_synthesize) + + receipt = sub.add_parser("receipt", help="Print the latest build receipt.") + receipt.add_argument("build", help="Build directory or manifest path.") + receipt.add_argument("--json", action="store_true", help="Print JSON result.") + receipt.set_defaults(func=command_receipt) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except BuildError as exc: + print(f"cento build: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cento_interactive.py b/scripts/cento_interactive.py index f1a9287..614add1 100755 --- a/scripts/cento_interactive.py +++ b/scripts/cento_interactive.py @@ -71,6 +71,16 @@ def format_overview() -> str: lines.extend(["", "Notes:"]) for note in notes: lines.append(f" - {note}") + checklist = docs.get("checklist", []) + if checklist: + lines.extend(["", "Checklist:"]) + for item in checklist: + if isinstance(item, dict): + name = item.get("name", "") + summary = item.get("summary", "") + lines.append(f" - {name}: {summary}" if name else f" - {summary}") + else: + lines.append(f" - {item}") lines.extend(["", "Built-ins:"]) for command in docs.get("commands", []): lines.append(f" {command.get('usage', '')} {command.get('summary', '')}") diff --git a/scripts/cento_openai_worker.py b/scripts/cento_openai_worker.py new file mode 100644 index 0000000..58b27eb --- /dev/null +++ b/scripts/cento_openai_worker.py @@ -0,0 +1,751 @@ +#!/usr/bin/env python3 +"""OpenAI Responses API worker for Cento worksets. + +The API worker produces structured artifacts only. It never mutates repository +files; Cento's local workset materializer owns file writes and patch bundles. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONFIG = ROOT / ".cento" / "api_workers.yaml" +RESPONSES_URL = "https://api.openai.com/v1/responses" + +SCHEMA_API_WORKER_ARTIFACT = "cento.api_worker_artifact.v1" + + +def load_local_cento_secrets() -> None: + config_root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + secrets_path = Path(os.environ.get("CENTO_SECRETS_ENV", config_root / "cento" / "secrets.env")) + try: + lines = secrets_path.read_text(encoding="utf-8").splitlines() + except OSError: + return + allowed = { + "OPENAI_API_KEY", + "CENTO_OPENAI_PLANNER_MODEL", + "CENTO_OPENAI_WORKER_MODEL", + "CENTO_OPENAI_REVIEWER_MODEL", + "CENTO_OPENAI_PRO_MODEL", + } + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("export "): + stripped = stripped[7:].lstrip() + if "=" not in stripped: + continue + key, raw_value = stripped.split("=", 1) + key = key.strip() + if key not in allowed or os.environ.get(key): + continue + try: + parsed = shlex.split(raw_value, posix=True) + except ValueError: + parsed = [raw_value.strip().strip("\"'")] + os.environ[key] = parsed[0] if parsed else "" + + +load_local_cento_secrets() + + +class WorkerError(RuntimeError): + """Expected worker failure.""" + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise WorkerError(f"file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise WorkerError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(payload, dict): + raise WorkerError(f"expected JSON object in {path}") + return payload + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def load_api_config(path: Path = DEFAULT_CONFIG) -> dict[str, Any]: + if not path.is_absolute(): + path = ROOT / path + try: + import yaml # type: ignore + + payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except FileNotFoundError as exc: + raise WorkerError(f"api worker config not found: {rel(path)}") from exc + except Exception as exc: + raise WorkerError(f"failed to load api worker config {rel(path)}: {exc}") from exc + if not isinstance(payload, dict): + raise WorkerError(f"{rel(path)} must contain a mapping") + return payload + + +def string_schema(description: str = "") -> dict[str, Any]: + schema = {"type": "string"} + if description: + schema["description"] = description + return schema + + +def string_array_schema(description: str = "") -> dict[str, Any]: + schema: dict[str, Any] = {"type": "array", "items": {"type": "string"}} + if description: + schema["description"] = description + return schema + + +def path_content_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": string_schema("Repo-relative path to materialize."), + "content": string_schema("Complete UTF-8 file content for the path."), + }, + "required": ["path", "content"], + "additionalProperties": False, + } + + +OUTPUT_SCHEMAS: dict[str, dict[str, Any]] = { + "docs_section.v1": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "enum": ["docs_section.v1"]}, + "title": string_schema("Section title."), + "summary": string_schema("Concise section summary."), + "badges": string_array_schema("Status, version, or state badges."), + "body": string_schema("Primary section body or implementation notes."), + "acceptance_criteria": string_array_schema("Concrete acceptance criteria."), + "owned_path_contents": { + "type": "array", + "items": path_content_schema(), + "description": "Optional complete file contents for owned paths.", + }, + }, + "required": ["schema_version", "title", "summary", "badges", "body", "acceptance_criteria", "owned_path_contents"], + "additionalProperties": False, + }, + "workset_plan.v1": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "enum": ["workset_plan.v1"]}, + "summary": string_schema("Plan summary."), + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": string_schema(), + "title": string_schema(), + "description": string_schema(), + "depends_on": string_array_schema(), + "write_paths": string_array_schema(), + "runtime_profile": string_schema(), + "output_schema": string_schema(), + }, + "required": ["id", "title", "description", "depends_on", "write_paths", "runtime_profile", "output_schema"], + "additionalProperties": False, + }, + }, + "risks": string_array_schema(), + }, + "required": ["schema_version", "summary", "tasks", "risks"], + "additionalProperties": False, + }, + "validation_review.v1": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "enum": ["validation_review.v1"]}, + "status": {"type": "string", "enum": ["passed", "failed", "review"]}, + "summary": string_schema(), + "findings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]}, + "path": string_schema(), + "line": {"type": "integer"}, + "message": string_schema(), + }, + "required": ["severity", "path", "line", "message"], + "additionalProperties": False, + }, + }, + "evidence": string_array_schema(), + "recommended_next_steps": string_array_schema(), + }, + "required": ["schema_version", "status", "summary", "findings", "evidence", "recommended_next_steps"], + "additionalProperties": False, + }, + "patch_proposal.v1": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "enum": ["patch_proposal.v1"]}, + "summary": string_schema(), + "owned_path_contents": { + "type": "array", + "items": path_content_schema(), + "description": "Complete file contents for proposed owned-path changes.", + }, + "risks": string_array_schema(), + "validation": string_array_schema(), + }, + "required": ["schema_version", "summary", "owned_path_contents", "risks", "validation"], + "additionalProperties": False, + }, + "hard_proreq_plan.v1": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "enum": ["cento.hard_proreq_backend_plan.v1"]}, + "summary": string_schema("Backend-only plan summary."), + "backend_workstreams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": string_schema(), + "title": string_schema(), + "intent": string_schema(), + "owned_paths": string_array_schema(), + "read_paths": string_array_schema(), + "depends_on": string_array_schema(), + "validation_commands": string_array_schema(), + "handoff_artifacts": string_array_schema(), + }, + "required": ["id", "title", "intent", "owned_paths", "read_paths", "depends_on", "validation_commands", "handoff_artifacts"], + "additionalProperties": False, + }, + }, + "integration_plan": string_array_schema(), + "validation_plan": string_array_schema(), + "parallelization_notes": string_array_schema(), + "codex_exec_prompts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": string_schema(), + "prompt": string_schema(), + "output_schema": string_schema(), + }, + "required": ["id", "prompt", "output_schema"], + "additionalProperties": False, + }, + }, + "risks": string_array_schema(), + }, + "required": ["schema_version", "summary", "backend_workstreams", "integration_plan", "validation_plan", "parallelization_notes", "codex_exec_prompts", "risks"], + "additionalProperties": False, + }, +} + + +def artifact_type_for_schema(schema_name: str) -> str: + if schema_name.endswith(".v1"): + schema_name = schema_name[:-3] + return schema_name.replace("-", "_") + + +def api_worker_artifact_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "schema_version": {"type": "string", "enum": [SCHEMA_API_WORKER_ARTIFACT]}, + "worker_id": {"type": "string"}, + "task_id": {"type": "string"}, + "status": {"type": "string", "enum": ["completed", "failed"]}, + "artifact_type": {"type": "string"}, + "owned_paths": {"type": "array", "items": {"type": "string"}}, + "content": {"type": "object"}, + "cost_usd_estimate": {"type": "number"}, + "errors": {"type": "array", "items": {"type": "string"}}, + }, + "required": [ + "schema_version", + "worker_id", + "task_id", + "status", + "artifact_type", + "owned_paths", + "content", + "cost_usd_estimate", + "errors", + ], + "additionalProperties": False, + } + + +def type_matches(value: Any, expected: str) -> bool: + if expected == "object": + return isinstance(value, dict) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "boolean": + return isinstance(value, bool) + if expected == "null": + return value is None + return True + + +def validate_json_schema(value: Any, schema: dict[str, Any], path: str = "$") -> list[str]: + errors: list[str] = [] + expected_type = schema.get("type") + if isinstance(expected_type, list): + if not any(type_matches(value, str(item)) for item in expected_type): + errors.append(f"{path} must be one of: " + ", ".join(str(item) for item in expected_type)) + return errors + elif isinstance(expected_type, str): + if not type_matches(value, expected_type): + errors.append(f"{path} must be {expected_type}") + return errors + + if "enum" in schema and value not in schema["enum"]: + errors.append(f"{path} must be one of: " + ", ".join(str(item) for item in schema["enum"])) + + if isinstance(value, dict): + required = schema.get("required") or [] + for key in required: + if key not in value: + errors.append(f"{path}.{key} is required") + properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {} + if schema.get("additionalProperties") is False: + for key in value: + if key not in properties: + errors.append(f"{path}.{key} is not allowed") + for key, child_schema in properties.items(): + if key in value and isinstance(child_schema, dict): + errors.extend(validate_json_schema(value[key], child_schema, f"{path}.{key}")) + + if isinstance(value, list) and isinstance(schema.get("items"), dict): + item_schema = schema["items"] + for index, item in enumerate(value): + errors.extend(validate_json_schema(item, item_schema, f"{path}[{index}]")) + + return errors + + +def profile_config(config: dict[str, Any], name: str) -> dict[str, Any]: + profiles = config.get("profiles") + if not isinstance(profiles, dict): + raise WorkerError("api worker config requires profiles mapping") + profile = profiles.get(name) + if not isinstance(profile, dict): + available = ", ".join(sorted(str(item) for item in profiles)) or "" + raise WorkerError(f"api worker profile not found: {name}; available profiles: {available}") + return profile + + +def resolve_env_value(value: Any) -> tuple[str, str | None]: + text = str(value or "") + if text.startswith("${") and text.endswith("}"): + env_name = text[2:-1] + return os.environ.get(env_name, ""), env_name + return text, None + + +def positive_int_limit( + cli_value: int | None, + profile: dict[str, Any], + openai_config: dict[str, Any], + key: str, + default: int, +) -> int: + value: Any = cli_value + if value is None: + value = profile.get(key) + if value is None: + value = openai_config.get(key) + if value is None: + value = default + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise WorkerError(f"{key} must be an integer") from exc + if parsed <= 0: + raise WorkerError(f"{key} must be greater than zero") + return parsed + + +def build_openai_request( + task_request: dict[str, Any], + model: str, + output_schema_name: str, + *, + max_output_tokens: int, +) -> dict[str, Any]: + schema = OUTPUT_SCHEMAS.get(output_schema_name) + if schema is None: + raise WorkerError(f"unknown output schema: {output_schema_name}") + payload = { + "model": model, + "instructions": ( + "You are a Cento API worker. Return only structured JSON matching the provided schema. " + "Do not mutate files, do not propose shell commands, and do not include secrets." + ), + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": json.dumps(task_request, indent=2, sort_keys=True), + } + ], + } + ], + "text": { + "format": { + "type": "json_schema", + "name": output_schema_name.replace(".", "_").replace("-", "_"), + "description": f"Cento structured worker output for {output_schema_name}.", + "strict": True, + "schema": schema, + } + }, + } + payload["max_output_tokens"] = max_output_tokens + return payload + + +def extract_output_text(response: dict[str, Any]) -> str: + direct = response.get("output_text") + if isinstance(direct, str) and direct.strip(): + return direct + chunks: list[str] = [] + for item in response.get("output") or []: + if not isinstance(item, dict): + continue + for content in item.get("content") or []: + if not isinstance(content, dict): + continue + if content.get("type") in {"output_text", "text"} and isinstance(content.get("text"), str): + chunks.append(str(content["text"])) + if content.get("type") == "refusal" and isinstance(content.get("refusal"), str): + raise WorkerError("model refusal: " + str(content["refusal"])) + text = "".join(chunks).strip() + if not text: + raise WorkerError("response did not contain output_text") + return text + + +def estimate_cost(response: dict[str, Any] | None, profile: dict[str, Any], reserved_cost: float) -> tuple[float, dict[str, Any]]: + usage = response.get("usage") if isinstance(response, dict) else {} + if not isinstance(usage, dict): + usage = {} + input_tokens = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0) + output_tokens = int(usage.get("output_tokens") or usage.get("completion_tokens") or 0) + pricing = profile.get("pricing") if isinstance(profile.get("pricing"), dict) else {} + input_rate = pricing.get("input_usd_per_1m") + output_rate = pricing.get("output_usd_per_1m") + if isinstance(input_rate, (int, float)) and isinstance(output_rate, (int, float)): + cost = (input_tokens * float(input_rate) + output_tokens * float(output_rate)) / 1_000_000 + method = "usage_tokens_profile_pricing" + elif response is None: + cost = 0.0 + method = "not_dispatched" + else: + cost = max(0.0, reserved_cost) + method = "reserved_estimate_no_pricing" + return round(cost, 6), { + "usage": usage, + "pricing": pricing, + "estimate_method": method, + } + + +def write_outputs( + out_dir: Path, + *, + worker_id: str, + task_id: str, + output_schema: str, + owned_paths: list[str], + content: dict[str, Any], + status: str, + cost_usd: float, + cost_details: dict[str, Any], + errors: list[str], + started_at: str, + response_path: Path, + request_path: Path, +) -> dict[str, Any]: + completed_at = now_iso() + artifact = { + "schema_version": SCHEMA_API_WORKER_ARTIFACT, + "worker_id": worker_id, + "task_id": task_id, + "status": status, + "artifact_type": artifact_type_for_schema(output_schema), + "owned_paths": owned_paths, + "content": content, + "cost_usd_estimate": cost_usd, + "errors": errors, + } + artifact_errors = validate_json_schema(artifact, api_worker_artifact_schema()) + if artifact_errors and not errors: + artifact["status"] = "failed" + artifact["errors"] = artifact_errors + artifact_path = out_dir / "artifact.json" + cost_path = out_dir / "cost_receipt.json" + receipt_path = out_dir / "worker_receipt.json" + write_json(artifact_path, artifact) + cost_receipt = { + "schema_version": "cento.api_worker_cost_receipt.v1", + "worker_id": worker_id, + "task_id": task_id, + "provider": "openai", + "cost_usd_estimate": cost_usd, + **cost_details, + "written_at": completed_at, + } + write_json(cost_path, cost_receipt) + worker_receipt = { + "schema_version": "cento.api_worker_receipt.v1", + "worker_id": worker_id, + "task_id": task_id, + "status": artifact["status"], + "output_schema": output_schema, + "request": rel(request_path), + "response": rel(response_path), + "artifact": rel(artifact_path), + "cost_receipt": rel(cost_path), + "started_at": started_at, + "completed_at": completed_at, + "errors": artifact["errors"], + } + write_json(receipt_path, worker_receipt) + return { + "status": artifact["status"], + "artifact": rel(artifact_path), + "cost_receipt": rel(cost_path), + "worker_receipt": rel(receipt_path), + "cost_usd_estimate": cost_usd, + "errors": artifact["errors"], + } + + +def post_response(request_payload: dict[str, Any], api_key: str, timeout: int) -> dict[str, Any]: + body = json.dumps(request_payload).encode("utf-8") + request = urllib.request.Request( + RESPONSES_URL, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def command_run(args: argparse.Namespace) -> int: + started_at = now_iso() + out_dir = Path(args.out_dir) + if not out_dir.is_absolute(): + out_dir = ROOT / out_dir + out_dir.mkdir(parents=True, exist_ok=True) + request_path = out_dir / "request.json" + response_path = out_dir / "response.json" + + try: + task_request = read_json(Path(args.task_request) if Path(args.task_request).is_absolute() else ROOT / args.task_request) + config = load_api_config(Path(args.config)) + openai_config = config.get("openai") if isinstance(config.get("openai"), dict) else {} + if openai_config and openai_config.get("enabled") is False: + raise WorkerError("OpenAI API workers are disabled in .cento/api_workers.yaml") + profile = profile_config(config, args.profile) + if str(profile.get("provider") or "") != "openai": + raise WorkerError(f"profile {args.profile} provider must be openai") + if str(profile.get("endpoint") or "") != "responses": + raise WorkerError(f"profile {args.profile} endpoint must be responses") + output_schema = args.output_schema or str(profile.get("output_schema") or "") + if output_schema not in OUTPUT_SCHEMAS: + raise WorkerError(f"unknown output schema: {output_schema or ''}") + model, model_env = resolve_env_value(profile.get("model")) + request_model = model or (f"" if model_env else "") + max_input_chars = positive_int_limit(args.max_input_chars, profile, openai_config, "max_input_chars", 20_000) + max_output_tokens = positive_int_limit(args.max_output_tokens, profile, openai_config, "max_output_tokens", 2_000) + request_payload = build_openai_request( + task_request, + request_model, + output_schema, + max_output_tokens=max_output_tokens, + ) + write_json(request_path, request_payload) + request_text = str(request_payload["input"][0]["content"][0]["text"]) + if len(request_text) > max_input_chars: + raise WorkerError(f"request input is {len(request_text)} chars, above max_input_chars={max_input_chars}") + + worker_id = str(task_request.get("worker_id") or args.worker_id) + task_id = str(task_request.get("task_id") or task_request.get("id") or worker_id) + owned_paths = [str(item) for item in task_request.get("write_paths") or task_request.get("owned_paths") or []] + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key: + raise WorkerError("OPENAI_API_KEY is not set") + if not model: + raise WorkerError(f"model is not configured; set {model_env}" if model_env else "model is not configured") + + timeout = int(args.timeout or openai_config.get("timeout_seconds") or 45) + retry_attempts = int(args.retry_attempts if args.retry_attempts is not None else openai_config.get("retry_attempts") or 0) + last_error = "" + response_payload: dict[str, Any] | None = None + for attempt in range(retry_attempts + 1): + try: + response_payload = post_response(request_payload, api_key, timeout) + break + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + last_error = f"OpenAI HTTP {exc.code}: {body}" + if exc.code < 500 and exc.code != 429: + break + if attempt < retry_attempts: + time.sleep(min(2.0, 0.5 * (attempt + 1))) + except (urllib.error.URLError, TimeoutError) as exc: + last_error = f"OpenAI request failed: {exc}" + if attempt < retry_attempts: + time.sleep(min(2.0, 0.5 * (attempt + 1))) + if response_payload is None: + write_json(response_path, {"status": "failed", "error": last_error}) + raise WorkerError(last_error or "OpenAI request failed") + write_json(response_path, response_payload) + if response_payload.get("status") not in {None, "completed"}: + raise WorkerError(f"response status is not completed: {response_payload.get('status')}") + output_text = extract_output_text(response_payload) + try: + content = json.loads(output_text) + except json.JSONDecodeError as exc: + raise WorkerError(f"structured output was not valid JSON: {exc}") from exc + if not isinstance(content, dict): + raise WorkerError("structured output must be a JSON object") + schema_errors = validate_json_schema(content, OUTPUT_SCHEMAS[output_schema]) + if schema_errors: + raise WorkerError("structured output schema validation failed: " + "; ".join(schema_errors)) + cost_usd, cost_details = estimate_cost(response_payload, profile, float(args.reserved_cost_usd or 0.0)) + cost_details["limits"] = { + "request_input_chars": len(request_text), + "max_input_chars": max_input_chars, + "max_output_tokens": max_output_tokens, + } + result = write_outputs( + out_dir, + worker_id=worker_id, + task_id=task_id, + output_schema=output_schema, + owned_paths=owned_paths, + content=content, + status="completed", + cost_usd=cost_usd, + cost_details=cost_details, + errors=[], + started_at=started_at, + response_path=response_path, + request_path=request_path, + ) + except WorkerError as exc: + task_id = args.worker_id + worker_id = args.worker_id + output_schema = args.output_schema or "docs_section.v1" + owned_paths: list[str] = [] + try: + task_request = read_json(Path(args.task_request) if Path(args.task_request).is_absolute() else ROOT / args.task_request) + task_id = str(task_request.get("task_id") or task_request.get("id") or args.worker_id) + worker_id = str(task_request.get("worker_id") or args.worker_id) + owned_paths = [str(item) for item in task_request.get("write_paths") or task_request.get("owned_paths") or []] + if not request_path.exists(): + write_json(request_path, {"status": "not_dispatched", "task_request": task_request}) + except WorkerError: + if not request_path.exists(): + write_json(request_path, {"status": "not_dispatched"}) + if not response_path.exists(): + write_json(response_path, {"status": "failed", "error": str(exc)}) + result = write_outputs( + out_dir, + worker_id=worker_id, + task_id=task_id, + output_schema=output_schema, + owned_paths=owned_paths, + content={}, + status="failed", + cost_usd=0.0, + cost_details={"usage": {}, "pricing": {}, "estimate_method": "not_dispatched"}, + errors=[str(exc)], + started_at=started_at, + response_path=response_path, + request_path=request_path, + ) + + if args.json: + print(json.dumps(result, indent=2)) + else: + print(result["artifact"]) + print(result["cost_receipt"]) + return 0 if result["status"] == "completed" else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run one OpenAI Responses API worker for a Cento workset.") + sub = parser.add_subparsers(dest="command", required=True) + run = sub.add_parser("run", help="Call the Responses API and write structured worker artifacts.") + run.add_argument("task_request", help="JSON request context for one workset task.") + run.add_argument("--out-dir", required=True, help="Worker artifact output directory.") + run.add_argument("--profile", required=True, help="API worker profile from .cento/api_workers.yaml.") + run.add_argument("--config", default=str(DEFAULT_CONFIG), help="API worker config path.") + run.add_argument("--output-schema", help="Override profile output schema.") + run.add_argument("--worker-id", default="api_worker", help="Fallback worker id.") + run.add_argument("--timeout", type=int, help="HTTP timeout in seconds.") + run.add_argument("--retry-attempts", type=int, help="Retry attempts after the first request.") + run.add_argument("--reserved-cost-usd", type=float, default=0.0, help="Pre-dispatch budget reservation estimate.") + run.add_argument("--max-input-chars", type=int, help="Refuse to dispatch if serialized request input exceeds this many characters.") + run.add_argument("--max-output-tokens", type=int, help="Responses API max_output_tokens limit.") + run.add_argument("--json", action="store_true", help="Print JSON result.") + run.set_defaults(func=command_run) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cento_run_mode.py b/scripts/cento_run_mode.py new file mode 100644 index 0000000..fce69c6 --- /dev/null +++ b/scripts/cento_run_mode.py @@ -0,0 +1,584 @@ +#!/usr/bin/env python3 +"""Create a Cento execution-mode contract for an operator task.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import cento_build # noqa: E402 + +MODES_PATH = ROOT / ".cento" / "modes.yaml" +RUN_ROOT = ROOT / "workspace" / "runs" / "cento-run" + +DEFAULT_MODES: dict[str, dict[str, Any]] = { + "fast": { + "time_budget_minutes": 5, + "validation_tier": "smoke", + "info_policy": "infer", + "ask_policy": "blockers_only", + "commit_policy": "none", + "push_policy": "none", + "max_workers": 0, + "max_files_changed": 3, + "repair_attempts": 0, + "risk_acceptance": "medium", + "behavior": [ + "Patch the visible issue only.", + "Infer missing details when the choice is reversible.", + "Skip broad cleanup, refactors, PRs, and full regression.", + ], + }, + "standard": { + "time_budget_minutes": 15, + "validation_tier": "focused", + "info_policy": "ask_if_blocked", + "ask_policy": "one_batch_if_material", + "commit_policy": "local_commit", + "push_policy": "optional", + "max_workers": 2, + "max_files_changed": 8, + "repair_attempts": 1, + "risk_acceptance": "low_medium", + "behavior": [ + "Make a scoped product-quality patch.", + "Ask only if the wrong choice would waste work.", + "Run targeted validation and commit owned paths when clean.", + ], + }, + "thorough": { + "time_budget_minutes": 30, + "validation_tier": "product", + "info_policy": "ask_first", + "ask_policy": "requirements_or_options_first", + "commit_policy": "local_commit", + "push_policy": "branch", + "pr_policy": "draft", + "max_workers": 4, + "max_files_changed": None, + "repair_attempts": 3, + "risk_acceptance": "low", + "behavior": [ + "Plan first with options and budget.", + "Use explicit manifests, workers, and validation evidence.", + "Push a branch and prepare PR/taskstream evidence when requested.", + ], + }, +} + + +def load_modes() -> dict[str, dict[str, Any]]: + if not MODES_PATH.exists(): + return DEFAULT_MODES + try: + import yaml # type: ignore + + data = yaml.safe_load(MODES_PATH.read_text(encoding="utf-8")) or {} + modes = data.get("modes") if isinstance(data, dict) else None + if isinstance(modes, dict): + return {**DEFAULT_MODES, **modes} + except Exception: + return DEFAULT_MODES + return DEFAULT_MODES + + +def parse_args(argv: list[str]) -> argparse.Namespace: + modes = load_modes() + parser = argparse.ArgumentParser( + prog="cento run", + description="Create a Cento fast/standard/thorough execution contract.", + ) + parser.add_argument("mode_pos", nargs="?", choices=sorted(modes), help="Execution mode.") + parser.add_argument("--mode", choices=sorted(modes), help="Execution mode.") + parser.add_argument("--task", required=True, help="Operator task statement.") + parser.add_argument("--write", action="append", default=[], help="Owned writable path. Repeatable.") + parser.add_argument("--read", action="append", default=[], help="Relevant read-only path. Repeatable.") + parser.add_argument("--route", action="append", default=[], help="Target route or URL. Repeatable.") + parser.add_argument("--validation", help="Override validation tier.") + parser.add_argument("--commit", help="Override commit policy.") + parser.add_argument("--time-budget", help="Override time budget, e.g. 5m.") + parser.add_argument( + "--local-builder", + nargs="?", + const="fixture", + default=None, + help="Run one local builder runtime and collect a patch bundle. Optional runtime name; default fixture.", + ) + parser.add_argument("--runtime-profile", help="Named local builder runtime profile from .cento/runtimes.yaml.") + parser.add_argument("--manual-builder", action="store_true", help="Write the build prompt but do not launch a local builder.") + parser.add_argument("--apply", action="store_true", help="Apply an accepted local-builder patch to the operator worktree.") + parser.add_argument("--worker-timeout", type=int, default=None, help="Local builder timeout in seconds; runtime profiles can provide the default.") + parser.add_argument("--fixture-case", default="valid", choices=["valid", "unowned", "protected", "delete", "lockfile", "binary"], help="Fixture case for --local-builder fixture.") + parser.add_argument("--builder-command", help="Command template for --local-builder command.") + parser.add_argument("--allow-unsafe-command", action="store_true", help="Allow raw shell command runtime without a named profile.") + parser.add_argument("--allow-dirty-owned", action="store_true", help="Do not block if owned paths are dirty.") + parser.add_argument("--copy-prompt", action="store_true", help="Copy the generated prompt to the clipboard.") + parser.add_argument("--print-prompt", action="store_true", help="Print the generated prompt.") + parser.add_argument("--json", action="store_true", help="Print the contract JSON path/result only.") + args = parser.parse_args(argv) + args.mode_name = args.mode or args.mode_pos + if not args.mode_name: + parser.error("provide a mode, e.g. `cento run fast --task ...` or `cento run --mode fast --task ...`") + return args + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") + return slug[:48] or "task" + + +def run_git_status() -> list[str]: + result = subprocess.run( + ["git", "status", "--porcelain=v1", "--untracked-files=all"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + raise SystemExit(result.stderr.strip() or "git status failed") + return [line for line in result.stdout.splitlines() if line.strip()] + + +def normalize_path(value: str) -> str: + path = Path(value) + if path.is_absolute(): + try: + return str(path.resolve().relative_to(ROOT)) + except ValueError: + return str(path) + return str(path) + + +def status_path(line: str) -> str: + path = line[3:] + if " -> " in path: + path = path.split(" -> ", 1)[1] + return path.strip() + + +def path_overlaps(owner: str, changed: str) -> bool: + owner = owner.rstrip("/") + changed = changed.rstrip("/") + return changed == owner or changed.startswith(owner + "/") + + +def dirty_summary(write_paths: list[str]) -> tuple[list[str], list[str], list[str]]: + status_lines = run_git_status() + changed_paths = [status_path(line) for line in status_lines] + dirty_owned: list[str] = [] + for owner in write_paths: + dirty_owned.extend(path for path in changed_paths if path_overlaps(owner, path)) + dirty_owned = sorted(set(dirty_owned)) + dirty_unrelated = sorted(path for path in changed_paths if path not in dirty_owned) + return status_lines, dirty_owned, dirty_unrelated + + +def parse_minutes(value: str | None, fallback: Any) -> int: + if value is None: + try: + return int(fallback) + except Exception: + return 5 + match = re.fullmatch(r"\s*(\d+)\s*(m|min|minutes?)?\s*", value) + if not match: + raise SystemExit(f"Invalid --time-budget: {value}") + return int(match.group(1)) + + +def copy_to_clipboard(text: str) -> bool: + for command in (["pbcopy"], ["wl-copy"]): + if shutil.which(command[0]): + subprocess.run(command, input=text, text=True, check=False) + return True + for command in (["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]): + if shutil.which(command[0]): + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + start_new_session=True, + ) + assert process.stdin is not None + process.stdin.write(text) + process.stdin.close() + return True + return False + + +def prompt_text(contract: dict[str, Any]) -> str: + mode = contract["mode"] + policies = contract["policies"] + write_paths = contract["paths"]["write"] or [""] + read_paths = contract["paths"]["read"] or [""] + routes = contract["routes"] or [""] + dirty_note = ( + f"{len(contract['dirty']['unrelated_paths'])} unrelated dirty path(s) exist. Preserve them and stage only owned paths." + if contract["dirty"]["unrelated_paths"] + else "No unrelated dirty paths detected." + ) + build = contract["artifacts"].get("build") or {} + sections = [ + "You are executing inside Cento.", + "", + "Task:", + contract["task"], + "", + "Execution contract:", + f"- Mode: {mode}", + f"- Time budget: {contract['time_budget_minutes']} minutes", + f"- Validation tier: {policies['validation_tier']}", + f"- Info policy: {policies['info_policy']}", + f"- Ask policy: {policies['ask_policy']}", + f"- Commit policy: {policies['commit_policy']}", + f"- Push policy: {policies['push_policy']}", + f"- Risk acceptance: {policies['risk_acceptance']}", + "", + "Mode behavior:", + *[f"- {item}" for item in contract["mode_behavior"]], + "", + "Owned write paths:", + *[f"- {path}" for path in write_paths], + "", + "Read context:", + *[f"- {path}" for path in read_paths], + "", + "Target routes / URLs:", + *[f"- {route}" for route in routes], + "", + "Build package:", + f"- Manifest: {build.get('manifest', '')}", + f"- Builder prompt: {build.get('prompt', '')}", + f"- Integration receipt: {build.get('integration_receipt', '')}", + "", + "Dirty repo policy:", + "- Preserve unrelated work.", + "- Do not use git add -A.", + "- Stage only owned paths if commit policy permits.", + f"- {dirty_note}", + "", + "Required output:", + "- changed files", + f"- validation run according to {policies['validation_tier']}", + "- assumptions made", + "- known risks / skipped checks", + "- commit hash only if commit policy permits", + "", + ] + return "\n".join(sections) + + +def run_build_cli(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "cento_build.py"), *args], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + +def create_build_artifacts(args: argparse.Namespace, run_slug: str, validation_tier: str) -> dict[str, Any] | None: + if not args.write: + return None + build_id = f"run_{args.mode_name}_{run_slug}".replace("-", "_") + build_args = argparse.Namespace( + task=args.task, + description=args.task, + mode=args.mode_name, + write=args.write, + read=args.read, + route=args.route, + protect=[], + validation=validation_tier, + id=build_id, + allow_dirty_owned=args.allow_dirty_owned, + ) + manifest = cento_build.create_manifest(build_args) + build_dir = cento_build.BUILD_ROOT / str(manifest["id"]) + build_dir.mkdir(parents=True, exist_ok=True) + manifest_path = build_dir / "manifest.json" + prompt_path = build_dir / "builder.prompt.md" + cento_build.write_json(manifest_path, manifest) + prompt_path.write_text(cento_build.render_builder_prompt(manifest), encoding="utf-8") + cento_build.append_event(build_dir, "build_manifest_created", {"manifest_id": manifest["id"], "source": "cento_run"}) + cento_build.append_event(build_dir, "builder_prompt_created", {"path": cento_build.rel(prompt_path)}) + result = { + "build_id": manifest["id"], + "build_dir": cento_build.rel(build_dir), + "manifest": cento_build.rel(manifest_path), + "prompt": cento_build.rel(prompt_path), + "status": "pending", + } + local_builder_requested = bool(args.local_builder or args.runtime_profile) + if args.manual_builder or not local_builder_requested: + validation_receipt = cento_build.run_validation_receipt( + manifest, + build_dir, + skipped=True, + reason="manual builder mode; no worker patch collected by cento run", + ) + current_base = cento_build.git_value(["rev-parse", "HEAD"], "HEAD") + manifest_base = str((manifest.get("source") or {}).get("base_ref") or "HEAD") + integration_receipt = { + "schema_version": cento_build.SCHEMA_INTEGRATION_RECEIPT, + "manifest_id": manifest["id"], + "status": "pending", + "mode": manifest.get("mode"), + "integration_mode": "run_fast_manifest", + "patch_bundle": None, + "patch_path": None, + "touched_paths": [], + "checks": [ + {"name": "manifest_created", "status": "passed", "details": cento_build.rel(manifest_path)}, + {"name": "worker_patch_collected", "status": "pending", "details": "manual/no local-builder mode"}, + ], + "applied": False, + "dry_run": True, + "rejections": [], + "warnings": ["no worker patch collected by cento run"], + "risk_overrides": ["allow_dirty_owned"] if args.allow_dirty_owned else [], + "dirty_owned_paths": [], + "dirty_unrelated_paths": [], + "base_ref_manifest": manifest_base, + "base_ref_worker": None, + "base_ref_current": current_base, + "base_ref_match": cento_build.base_ref_matches(manifest_base, current_base), + "worktree_path": None, + "worktree_removed": None, + "validation_receipt": cento_build.rel(build_dir / "validation_receipt.json"), + "written_at": cento_build.now_iso(), + } + receipt_path = cento_build.write_integration_receipt(build_dir, integration_receipt) + cento_build.append_event(build_dir, "integration_receipt_pending", {"status": "pending"}) + cento_build.append_event(build_dir, "run_fast_completed", {"status": "pending", "reason": "no local builder"}) + result.update( + { + "integration_receipt": cento_build.rel(receipt_path), + "validation_receipt": cento_build.rel(build_dir / "validation_receipt.json"), + "validation_status": validation_receipt["status"], + } + ) + return result + + runtime = str(args.local_builder or "command") + try: + worker_result = cento_build.run_build_worker( + manifest_path, + worker_id="builder_1", + runtime=runtime, + use_worktree=True, + timeout=args.worker_timeout, + allow_dirty_owned=args.allow_dirty_owned, + fixture_case=args.fixture_case, + command_template=args.builder_command, + runtime_profile_name=args.runtime_profile, + allow_unsafe_command=args.allow_unsafe_command, + ) + except cento_build.BuildError as exc: + cento_build.write_taskstream_evidence(build_dir, manifest, status="blocked", changed_files=[], risk_overrides=[]) + cento_build.append_event(build_dir, "run_fast_completed", {"status": "blocked", "reason": str(exc)}) + result.update({"status": "blocked", "error": str(exc), "taskstream_evidence": cento_build.rel(build_dir / "taskstream_evidence.json")}) + return result + + result.update( + { + "worker_artifact": worker_result.get("worker_artifact"), + "patch_bundle": worker_result.get("patch_bundle"), + "patch": worker_result.get("patch"), + "handoff": worker_result.get("handoff"), + "worker_status": worker_result.get("worker_status"), + "runtime_profile": worker_result.get("runtime_profile"), + } + ) + if worker_result.get("status") != "accepted" or not worker_result.get("patch_bundle"): + cento_build.write_taskstream_evidence(build_dir, manifest, status="blocked", changed_files=[], risk_overrides=[]) + cento_build.append_event(build_dir, "run_fast_completed", {"status": "blocked", "reason": "worker rejected"}) + result.update({"status": "blocked", "error": "; ".join([str(item) for item in worker_result.get("errors") or []])}) + return result + + integrate_args = [ + "integrate", + cento_build.rel(manifest_path), + "--bundle", + str(worker_result["patch_bundle"]), + "--worktree", + "--dry-run", + ] + if args.allow_dirty_owned: + integrate_args.append("--allow-dirty-owned") + integrate_proc = run_build_cli(integrate_args) + result["integration_stdout"] = integrate_proc.stdout.strip() + if integrate_proc.returncode != 0: + cento_build.write_taskstream_evidence(build_dir, manifest, status="blocked", changed_files=[], risk_overrides=[]) + cento_build.append_event(build_dir, "run_fast_completed", {"status": "blocked", "reason": "integration rejected"}) + result.update( + { + "status": "blocked", + "integration_receipt": cento_build.rel(build_dir / "integration_receipt.json"), + "validation_receipt": cento_build.rel(build_dir / "validation_receipt.json"), + "error": integrate_proc.stderr.strip() or "integration rejected", + "taskstream_evidence": cento_build.rel(build_dir / "taskstream_evidence.json"), + } + ) + return result + + result.update( + { + "integration_receipt": cento_build.rel(build_dir / "integration_receipt.json"), + "validation_receipt": cento_build.rel(build_dir / "validation_receipt.json"), + "status": "accepted", + } + ) + if args.apply: + try: + apply_receipt = cento_build.apply_build_bundle( + manifest_path, + ROOT / str(worker_result["patch_bundle"]), + build_dir / "integration_receipt.json", + allow_dirty_owned=args.allow_dirty_owned, + ) + except cento_build.BuildError as exc: + cento_build.write_taskstream_evidence(build_dir, manifest, status="blocked", changed_files=[], risk_overrides=[]) + cento_build.append_event(build_dir, "run_fast_completed", {"status": "blocked", "reason": str(exc)}) + result.update({"status": "blocked", "error": str(exc), "taskstream_evidence": cento_build.rel(build_dir / "taskstream_evidence.json")}) + return result + result.update( + { + "apply_receipt": cento_build.rel(build_dir / "apply_receipt.json"), + "validation_receipt": cento_build.rel(build_dir / "validation_receipt.json"), + "taskstream_evidence": cento_build.rel(build_dir / "taskstream_evidence.json"), + "apply_status": apply_receipt.get("status"), + "validation_status": (cento_build.read_json(build_dir / "validation_receipt.json")).get("status") + if (build_dir / "validation_receipt.json").exists() + else None, + "status": "review" if apply_receipt.get("status") == "applied" else "blocked", + } + ) + cento_build.append_event(build_dir, "run_fast_completed", {"status": result["status"], "apply_status": apply_receipt.get("status")}) + else: + cento_build.write_taskstream_evidence( + build_dir, + manifest, + status="review", + changed_files=[str(item) for item in worker_result.get("touched_paths") or []], + risk_overrides=[], + ) + result["taskstream_evidence"] = cento_build.rel(build_dir / "taskstream_evidence.json") + cento_build.append_event(build_dir, "run_fast_completed", {"status": "review", "applied": False}) + return result + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + modes = load_modes() + mode_config = modes[args.mode_name] + write_paths = [normalize_path(path) for path in args.write] + read_paths = [normalize_path(path) for path in args.read] + status_lines, dirty_owned, dirty_unrelated = dirty_summary(write_paths) + if dirty_owned and not args.allow_dirty_owned: + print("Blocked: owned path is already dirty.", file=sys.stderr) + for path in dirty_owned: + print(f" {path}", file=sys.stderr) + print("Use --allow-dirty-owned only if you intentionally want to work with those changes.", file=sys.stderr) + return 2 + + now = datetime.now(timezone.utc) + run_slug = f"{now.strftime('%Y%m%d-%H%M%S')}-{args.mode_name}-{slugify(args.task)}" + run_dir = RUN_ROOT / run_slug + run_dir.mkdir(parents=True, exist_ok=True) + validation_tier = args.validation or str(mode_config.get("validation_tier") or "smoke") + commit_policy = args.commit or str(mode_config.get("commit_policy") or "none") + build_artifacts = create_build_artifacts(args, run_slug, validation_tier) + contract = { + "schema": "cento.execution-contract.v1", + "created_at": now.isoformat(), + "mode": args.mode_name, + "task": args.task, + "time_budget_minutes": parse_minutes(args.time_budget, mode_config.get("time_budget_minutes")), + "routes": args.route, + "paths": { + "write": write_paths, + "read": read_paths, + }, + "policies": { + "validation_tier": validation_tier, + "info_policy": str(mode_config.get("info_policy") or "infer"), + "ask_policy": str(mode_config.get("ask_policy") or "blockers_only"), + "commit_policy": commit_policy, + "push_policy": str(mode_config.get("push_policy") or "none"), + "risk_acceptance": str(mode_config.get("risk_acceptance") or "medium"), + "repair_attempts": mode_config.get("repair_attempts", 0), + "max_workers": mode_config.get("max_workers", 0), + "max_files_changed": mode_config.get("max_files_changed"), + }, + "mode_behavior": list(mode_config.get("behavior") or []), + "dirty": { + "status_lines": status_lines, + "owned_paths": dirty_owned, + "unrelated_paths": dirty_unrelated, + }, + "artifacts": { + "run_dir": str(run_dir.relative_to(ROOT)), + "contract": str((run_dir / "contract.json").relative_to(ROOT)), + "prompt": str((run_dir / "prompt.md").relative_to(ROOT)), + "build": build_artifacts, + }, + } + prompt = prompt_text(contract) + (run_dir / "contract.json").write_text(json.dumps(contract, indent=2, sort_keys=True) + "\n", encoding="utf-8") + (run_dir / "prompt.md").write_text(prompt, encoding="utf-8") + (run_dir / "dirty-baseline.txt").write_text("\n".join(status_lines) + ("\n" if status_lines else ""), encoding="utf-8") + + if args.copy_prompt and not copy_to_clipboard(prompt): + print("Clipboard unavailable; prompt was written to disk.", file=sys.stderr) + if args.print_prompt: + print(prompt) + return 0 + if args.json: + print(json.dumps(contract["artifacts"], indent=2, sort_keys=True)) + return 1 if build_artifacts and build_artifacts.get("status") == "blocked" else 0 + + print(f"Cento {args.mode_name} contract created") + print(f"Run dir: {contract['artifacts']['run_dir']}") + print(f"Prompt: {contract['artifacts']['prompt']}") + if build_artifacts: + print(f"Build manifest: {build_artifacts['manifest']}") + print(f"Build prompt: {build_artifacts['prompt']}") + if build_artifacts.get("worker_artifact"): + print(f"Worker artifact: {build_artifacts['worker_artifact']}") + if build_artifacts.get("patch_bundle"): + print(f"Patch bundle: {build_artifacts['patch_bundle']}") + if build_artifacts.get("integration_receipt"): + print(f"Integration receipt: {build_artifacts['integration_receipt']}") + if build_artifacts.get("apply_receipt"): + print(f"Apply receipt: {build_artifacts['apply_receipt']}") + if build_artifacts.get("validation_receipt"): + print(f"Validation receipt: {build_artifacts['validation_receipt']}") + if build_artifacts.get("taskstream_evidence"): + print(f"Taskstream evidence: {build_artifacts['taskstream_evidence']}") + if build_artifacts.get("status") == "review": + print("Fast task complete: patch applied, validation evidence written, commit not created.") + elif build_artifacts.get("status") == "blocked": + print(f"Fast task blocked: {build_artifacts.get('error', 'see receipts')}", file=sys.stderr) + print(f"Mode: {args.mode_name} · {contract['time_budget_minutes']}m · {validation_tier} · commit {commit_policy}") + if dirty_unrelated: + print(f"Dirty unrelated paths preserved: {len(dirty_unrelated)}") + return 1 if build_artifacts and build_artifacts.get("status") == "blocked" else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/cento_runtime.py b/scripts/cento_runtime.py new file mode 100644 index 0000000..b3787be --- /dev/null +++ b/scripts/cento_runtime.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Inspect Cento local worker runtime profiles.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import cento_build # noqa: E402 + + +def profile_summary(name: str, profile: dict[str, Any]) -> dict[str, Any]: + validation = cento_build.validate_runtime_profile(name, profile) + runtime_type = str(profile.get("type") or "") + executable = None + executable_available = None + if runtime_type == "command": + argv = profile.get("argv") + if isinstance(argv, list) and argv: + executable = str(argv[0]) + executable_available = shutil.which(executable) is not None + return { + "name": name, + "type": runtime_type or None, + "status": validation["status"], + "timeout_seconds": profile.get("timeout_seconds"), + "max_changed_files": profile.get("max_changed_files"), + "max_patch_lines": profile.get("max_patch_lines"), + "executable": executable, + "executable_available": executable_available, + "errors": validation["errors"], + "warnings": validation["warnings"], + } + + +def command_list(args: argparse.Namespace) -> int: + try: + profiles = cento_build.load_runtime_profiles() + except cento_build.BuildError as exc: + print(f"cento runtime list: {exc}", file=sys.stderr) + return 1 + payload = {"profiles": [profile_summary(name, profiles[name]) for name in sorted(profiles)]} + if args.json: + print(json.dumps(payload, indent=2)) + else: + if not payload["profiles"]: + print("No runtime profiles configured.") + return 0 + for item in payload["profiles"]: + executable = "" + if item["executable"]: + availability = "available" if item["executable_available"] else "missing" + executable = f" executable={item['executable']}({availability})" + print(f"{item['name']:<18} {item['type']:<8} {item['status']}{executable}") + return 0 + + +def command_check(args: argparse.Namespace) -> int: + try: + profiles = cento_build.load_runtime_profiles() + except cento_build.BuildError as exc: + print(f"cento runtime check: {exc}", file=sys.stderr) + return 1 + if args.name not in profiles: + print(f"cento runtime check: runtime profile not found: {args.name}", file=sys.stderr) + return 1 + summary = profile_summary(args.name, profiles[args.name]) + errors = [str(item) for item in summary["errors"]] + warnings = [str(item) for item in summary["warnings"]] + if args.require_executable and summary["executable"] and not summary["executable_available"]: + errors.append(f"runtime executable not found on PATH: {summary['executable']}") + if summary["executable"] and not summary["executable_available"]: + warnings.append(f"runtime executable not found on PATH: {summary['executable']}") + status = "passed" if not errors else "failed" + payload = {**summary, "status": status, "errors": errors, "warnings": warnings, "profile_path": cento_build.rel(cento_build.RUNTIMES_PATH)} + if args.json: + print(json.dumps(payload, indent=2)) + else: + print(f"runtime check {args.name}: {status}") + if payload["executable"]: + availability = "available" if payload["executable_available"] else "missing" + print(f"executable: {payload['executable']} ({availability})") + for warning in warnings: + print(f"warning: {warning}", file=sys.stderr) + for error in errors: + print(f"error: {error}", file=sys.stderr) + return 0 if status == "passed" else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="cento runtime", description="Inspect local builder runtime profiles.") + sub = parser.add_subparsers(dest="command", required=True) + + list_cmd = sub.add_parser("list", help="List runtime profiles from .cento/runtimes.yaml.") + list_cmd.add_argument("--json", action="store_true", help="Print JSON result.") + list_cmd.set_defaults(func=command_list) + + check = sub.add_parser("check", help="Validate one runtime profile.") + check.add_argument("name", help="Runtime profile name.") + check.add_argument("--json", action="store_true", help="Print JSON result.") + check.add_argument("--require-executable", action="store_true", help="Fail when a command runtime executable is missing.") + check.set_defaults(func=command_check) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cento_temp.sh b/scripts/cento_temp.sh index f746283..a6ae679 100755 --- a/scripts/cento_temp.sh +++ b/scripts/cento_temp.sh @@ -2,217 +2,22 @@ set -euo pipefail -ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -REDMINE_DIR="$ROOT_DIR/experimental/redmine-career-consulting" -HELPER="$REDMINE_DIR/scripts/redmine-compose-root.sh" -SUDOERS_FILE="/etc/sudoers.d/cento-redmine-cutover" -TEMP_ISSUE_ID="${CENTO_TEMP_ISSUE_ID:-133}" +COPY_FILE="/home/alice/projects/cento/workspace/runs/temp/cento-ultimate-ai-reference.md" -case "$TEMP_ISSUE_ID" in - ""|*[!0-9]*) - echo "CENTO_TEMP_ISSUE_ID must be a numeric issue id." >&2 - exit 2 - ;; -esac +if [[ $# -ne 1 || "${1:-}" != "run" ]]; then + printf 'Usage: cento temp run\n' >&2 + exit 2 +fi -RUN_DIR="$ROOT_DIR/workspace/runs/agent-work/$TEMP_ISSUE_ID" +if [[ ! -f "$COPY_FILE" ]]; then + printf 'Cento temp copy file is missing: %s\n' "$COPY_FILE" >&2 + exit 1 +fi -usage() { - cat <<'USAGE' -Usage: cento run temp 1 [rollback|status] +if ! command -v pbcopy >/dev/null 2>&1; then + printf 'pbcopy is not available on PATH.\n' >&2 + exit 1 +fi -Temp commands: - 1 Install least-privilege sudoers entry, stop Redmine, validate replacement, and update the configured issue id. - 1 rollback Start Redmine again using the same helper. - 1 status Show Redmine compose status through the helper. -USAGE -} - -validate_sudoers() { - sudo visudo -cf "$SUDOERS_FILE" >/dev/null -} - -install_cutover_sudoers() { - local line - if sudo -n "$HELPER" config >/dev/null 2>&1; then - printf 'Sudoers entry already works for cutover helper.\n' - return 0 - fi - line="$(whoami) ALL=(root) NOPASSWD: $HELPER" - printf 'Installing sudoers entry: %s\n' "$SUDOERS_FILE" - printf '%s\n' "$line" | sudo tee "$SUDOERS_FILE" >/dev/null - sudo chmod 0440 "$SUDOERS_FILE" - validate_sudoers -} - -run_cutover_stop() { - mkdir -p "$RUN_DIR" - printf 'Stopping Redmine through cutover helper...\n' - ( - cd "$REDMINE_DIR" - ./scripts/redmine.sh cutover-stop - ) 2>&1 | tee "$RUN_DIR/operator-cutover-stop.log" -} - -run_cutover_start() { - mkdir -p "$RUN_DIR" - printf 'Starting Redmine through cutover helper...\n' - ( - cd "$REDMINE_DIR" - ./scripts/redmine.sh cutover-start - ) 2>&1 | tee "$RUN_DIR/operator-cutover-start.log" -} - -run_cutover_status() { - ( - cd "$REDMINE_DIR" - ./scripts/redmine.sh cutover-status - ) -} - -validate_replacement() { - mkdir -p "$RUN_DIR" - printf 'Validating replacement backend while Redmine is stopped...\n' - ( - cd "$ROOT_DIR" - export CENTO_AGENT_WORK_BACKEND=replacement - local title evidence_path story_path created_json issue_id done_json status - title="Cento Replacement-Only Cutover Smoke $(date +%Y%m%d-%H%M%S)" - evidence_path="$RUN_DIR/operator-replacement-e2e-evidence.txt" - story_path="$RUN_DIR/operator-replacement-e2e-story.json" - cat > "$evidence_path" < "$story_path" </dev/null - python3 scripts/agent_work.py update "$issue_id" --status validating --role validator --note "Replacement-only update path works while Redmine is stopped." >/dev/null - python3 scripts/agent_work.py dispatch "$issue_id" --node linux --agent temp-cutover --role validator --dry-run | grep -F "issue-$issue_id-" >/dev/null - python3 scripts/agent_work.py validate "$issue_id" --result pass --evidence "$evidence_path" --note "Replacement-only validation evidence while Redmine is stopped." >/dev/null - done_json=$(python3 scripts/agent_work.py update "$issue_id" --status done --role validator --note "Replacement-only cutover smoke complete." --json) - status=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$done_json") - [[ "$status" == "Done" ]] - python3 scripts/agent_work.py show "$issue_id" --json >/dev/null - python3 scripts/agent_work.py list --all --json >/dev/null - printf 'replacement-only e2e ok: #%s %s\n' "$issue_id" "$title" - printf 'backend=replacement evidence=%s\n' "$evidence_path" - ) 2>&1 | tee "$RUN_DIR/operator-replacement-e2e.log" -} - -mark_issue_passed() { - local note - note=$(cat <&2 - exit 2 - ;; - esac - ;; - ""|-h|--help|help) - usage - ;; - *) - usage >&2 - exit 2 - ;; - esac -} - -main "$@" +pbcopy < "$COPY_FILE" +printf 'Copied: %s\n' "$COPY_FILE" diff --git a/scripts/cento_workset.py b/scripts/cento_workset.py new file mode 100644 index 0000000..b02460a --- /dev/null +++ b/scripts/cento_workset.py @@ -0,0 +1,1822 @@ +#!/usr/bin/env python3 +"""Minimal local workset runner for non-overlapping Cento Build tasks.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import json +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import cento_build # noqa: E402 +import cento_openai_worker # noqa: E402 + +SCHEMA_WORKSET = "cento.workset.v1" +SCHEMA_WORKSET_RECEIPT = "cento.workset_receipt.v1" +SCHEMA_MATERIALIZATION_RECEIPT = "cento.artifact_materialization_receipt.v1" +WORKSET_ROOT = ROOT / ".cento" / "worksets" +API_CONFIG_PATH = ROOT / ".cento" / "api_workers.yaml" + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def rel(path: Path) -> str: + return cento_build.rel(path) + + +def write_json(path: Path, payload: Any) -> None: + cento_build.write_json(path, payload) + + +def read_json(path: Path) -> dict[str, Any]: + return cento_build.read_json(path) + + +def append_event(workset_dir: Path, event: str, payload: dict[str, Any] | None = None) -> None: + workset_dir.mkdir(parents=True, exist_ok=True) + row = {"ts": now_iso(), "event": event} + if payload: + row.update(payload) + with (workset_dir / "events.ndjson").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, sort_keys=True) + "\n") + + +def task_id(task: dict[str, Any]) -> str: + value = task.get("id") + if not isinstance(value, str) or not value: + raise cento_build.BuildError("each workset task requires id") + return value + + +def task_dependencies(task: dict[str, Any]) -> list[str]: + raw = task.get("depends_on", task.get("dependencies", [])) + if raw is None: + raw = [] + if not isinstance(raw, list) or not all(isinstance(item, str) and item for item in raw): + raise cento_build.BuildError(f"task {task_id(task)} dependencies must be a list of task ids") + return list(raw) + + +def task_write_paths(task: dict[str, Any]) -> list[str]: + raw = task.get("write_paths") + if not isinstance(raw, list) or not raw: + raise cento_build.BuildError(f"task {task_id(task)} write_paths must be a non-empty list") + paths = cento_build.normalize_paths([str(item) for item in raw]) + for path in paths: + if cento_build.has_glob(path): + raise cento_build.BuildError(f"task {task_id(task)} uses glob write_path; workset v1 requires explicit exclusive paths: {path}") + return paths + + +def task_read_paths(task: dict[str, Any], workset: dict[str, Any]) -> list[str]: + raw = task.get("read_paths", workset.get("read_paths", [])) + if raw is None: + raw = [] + if not isinstance(raw, list): + raise cento_build.BuildError(f"task {task_id(task)} read_paths must be a list") + return cento_build.normalize_paths([str(item) for item in raw]) + + +def task_routes(task: dict[str, Any], workset: dict[str, Any]) -> list[str]: + raw = task.get("routes", task.get("route", workset.get("routes", workset.get("route", [])))) + if raw is None: + return [] + if isinstance(raw, str): + return [raw] + if isinstance(raw, list) and all(isinstance(item, str) for item in raw): + return [str(item) for item in raw] + raise cento_build.BuildError(f"task {task_id(task)} routes must be a string or list") + + +def path_overlaps(left: str, right: str) -> bool: + left = cento_build.normalize_path(left) + right = cento_build.normalize_path(right) + return cento_build.path_matches(left, right) or cento_build.path_matches(right, left) + + +def load_workset(path: Path) -> dict[str, Any]: + if not path.is_absolute(): + path = ROOT / path + return cento_build.read_json(path) + + +def validate_workset(workset: dict[str, Any], *, allow_missing_write_paths: bool = False) -> dict[str, Any]: + errors: list[str] = [] + warnings: list[str] = [] + + schema = workset.get("schema_version") + if schema not in {None, SCHEMA_WORKSET}: + errors.append(f"schema_version must be {SCHEMA_WORKSET}") + workset_id = workset.get("id") + if not isinstance(workset_id, str) or not workset_id: + errors.append("id is required") + mode = str(workset.get("mode") or "fast") + if mode not in cento_build.load_modes(): + errors.append(f"mode must exist in .cento/modes.yaml: {mode}") + tasks = workset.get("tasks") + if not isinstance(tasks, list) or not tasks: + errors.append("tasks must be a non-empty list") + tasks = [] + + by_id: dict[str, dict[str, Any]] = {} + writes_by_task: dict[str, list[str]] = {} + for item in tasks: + if not isinstance(item, dict): + errors.append("each task must be an object") + continue + try: + tid = task_id(item) + except cento_build.BuildError as exc: + errors.append(str(exc)) + continue + if tid in by_id: + errors.append(f"duplicate task id: {tid}") + continue + by_id[tid] = item + try: + writes_by_task[tid] = task_write_paths(item) + except cento_build.BuildError as exc: + errors.append(str(exc)) + continue + for write_path in writes_by_task[tid]: + if not allow_missing_write_paths and not cento_build.path_exists(write_path): + errors.append(f"task {tid} write path does not exist: {write_path}") + if cento_build.path_is_protected(write_path, cento_build.DEFAULT_PROTECTED_PATHS): + errors.append(f"task {tid} write path is protected: {write_path}") + + task_ids = set(by_id) + for tid, task in by_id.items(): + try: + for dep in task_dependencies(task): + if dep not in task_ids: + errors.append(f"task {tid} depends on unknown task: {dep}") + except cento_build.BuildError as exc: + errors.append(str(exc)) + + ids = sorted(writes_by_task) + for index, left_id in enumerate(ids): + for right_id in ids[index + 1 :]: + for left in writes_by_task[left_id]: + for right in writes_by_task[right_id]: + if path_overlaps(left, right): + errors.append( + f"overlapping write paths are rejected in workset v1: {left_id}:{left} overlaps {right_id}:{right}" + ) + + visiting: set[str] = set() + visited: set[str] = set() + + def visit(tid: str) -> None: + if tid in visited: + return + if tid in visiting: + errors.append(f"dependency cycle includes task: {tid}") + return + visiting.add(tid) + for dep in task_dependencies(by_id[tid]): + if dep in by_id: + visit(dep) + visiting.remove(tid) + visited.add(tid) + + for tid in list(by_id): + try: + visit(tid) + except cento_build.BuildError as exc: + errors.append(str(exc)) + + return { + "status": "passed" if not errors else "failed", + "errors": sorted(set(errors)), + "warnings": warnings, + "task_count": len(by_id), + "write_paths": writes_by_task, + } + + +def normalize_workset(workset: dict[str, Any]) -> dict[str, Any]: + mode = str(workset.get("mode") or "fast") + normalized_tasks: list[dict[str, Any]] = [] + for task in workset.get("tasks") or []: + if not isinstance(task, dict): + continue + normalized_tasks.append( + { + "id": task_id(task), + "worker_id": str(task.get("worker_id") or task_id(task)), + "task": str(task.get("task") or task.get("title") or task_id(task)), + "description": str(task.get("description") or task.get("task") or task.get("title") or task_id(task)), + "write_paths": task_write_paths(task), + "read_paths": task_read_paths(task, workset), + "routes": task_routes(task, workset), + "depends_on": task_dependencies(task), + "runtime_profile": str(task.get("runtime_profile") or task.get("profile") or ""), + "api_profile": str(task.get("api_profile") or ""), + "output_schema": str(task.get("output_schema") or ""), + "artifact_type": str(task.get("artifact_type") or ""), + "cost_usd_estimate": task.get("cost_usd_estimate"), + } + ) + return { + "schema_version": SCHEMA_WORKSET, + "id": str(workset.get("id")), + "mode": mode, + "max_parallel": int(workset.get("max_parallel") or 1), + "tasks": normalized_tasks, + } + + +def make_task_manifest( + workset: dict[str, Any], + task: dict[str, Any], + *, + run_id: str, + validation_tier: str | None, + allow_dirty_owned: bool, + allow_creates: bool = False, +) -> tuple[Path, dict[str, Any]]: + build_id = f"workset_{cento_build.slugify(run_id)}_{cento_build.slugify(task['id'])}" + build_args = argparse.Namespace( + task=task["task"], + description=task["description"], + mode=workset["mode"], + write=task["write_paths"], + read=task["read_paths"], + route=task["routes"], + protect=[], + validation=validation_tier, + id=build_id, + allow_dirty_owned=allow_dirty_owned, + ) + manifest = cento_build.create_manifest(build_args) + if allow_creates: + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + manifest["policies"] = {**policies, "allow_creates": True} + manifest["workset"] = {"id": workset["id"], "run_id": run_id, "task_id": task["id"]} + build_dir = cento_build.BUILD_ROOT / str(manifest["id"]) + build_dir.mkdir(parents=True, exist_ok=True) + manifest_path = build_dir / "manifest.json" + prompt_path = build_dir / "builder.prompt.md" + write_json(manifest_path, manifest) + prompt_path.write_text(cento_build.render_builder_prompt(manifest), encoding="utf-8") + cento_build.append_event(build_dir, "build_manifest_created", {"manifest_id": manifest["id"], "source": "cento_workset"}) + cento_build.append_event(build_dir, "builder_prompt_created", {"path": rel(prompt_path)}) + return manifest_path, manifest + + +def run_task_worker( + workset: dict[str, Any], + task: dict[str, Any], + *, + run_id: str, + runtime_profile: str, + runtime: str, + fixture_case: str, + worker_timeout: int | None, + validation_tier: str | None, + allow_dirty_owned: bool, + allow_unsafe_command: bool, + command_template: str | None, +) -> dict[str, Any]: + manifest_path, manifest = make_task_manifest( + workset, + task, + run_id=run_id, + validation_tier=validation_tier, + allow_dirty_owned=allow_dirty_owned, + ) + worker_result = cento_build.run_build_worker( + manifest_path, + worker_id="builder_1", + runtime=runtime, + use_worktree=True, + timeout=worker_timeout, + allow_dirty_owned=allow_dirty_owned, + fixture_case=fixture_case, + runtime_profile_name=runtime_profile, + allow_unsafe_command=allow_unsafe_command, + command_template=command_template, + ) + return { + "task_id": task["id"], + "manifest": rel(manifest_path), + "build_id": manifest.get("id"), + "build_dir": rel(cento_build.build_dir_for_manifest(manifest, manifest_path)), + "worker": worker_result, + } + + +def load_api_worker_config(path: Path = API_CONFIG_PATH) -> dict[str, Any]: + return cento_openai_worker.load_api_config(path) + + +def api_openai_config(config: dict[str, Any]) -> dict[str, Any]: + raw = config.get("openai") if isinstance(config.get("openai"), dict) else {} + return dict(raw) + + +def api_profile_name_for_task(task: dict[str, Any], default_profile: str) -> str: + return str(task.get("api_profile") or task.get("runtime_profile") or default_profile) + + +def api_output_schema_for_profile(config: dict[str, Any], profile_name: str, task: dict[str, Any]) -> str: + if task.get("output_schema"): + return str(task["output_schema"]) + profile = cento_openai_worker.profile_config(config, profile_name) + return str(profile.get("output_schema") or "docs_section.v1") + + +def api_cost_estimate(config: dict[str, Any], profile_name: str, task: dict[str, Any]) -> float: + openai_config = api_openai_config(config) + raw_default = openai_config.get("cost_usd_estimate_per_request") + default_estimate = max(0.0, float(raw_default)) if isinstance(raw_default, (int, float)) else 0.10 + estimate = default_estimate + raw_task = task.get("cost_usd_estimate") + if isinstance(raw_task, (int, float)): + estimate = max(0.0, float(raw_task)) + else: + profile = cento_openai_worker.profile_config(config, profile_name) + raw_profile = profile.get("cost_usd_estimate") + if isinstance(raw_profile, (int, float)): + estimate = max(0.0, float(raw_profile)) + raw_minimum = openai_config.get("minimum_cost_usd_estimate_per_request", default_estimate) + minimum = max(0.0, float(raw_minimum)) if isinstance(raw_minimum, (int, float)) else default_estimate + return max(estimate, minimum) + + +def api_positive_int_limit(config: dict[str, Any], profile_name: str, task: dict[str, Any], key: str, default: int) -> int: + profile = cento_openai_worker.profile_config(config, profile_name) + openai_config = api_openai_config(config) + value: Any = task.get(key) + if value is None: + value = profile.get(key) + if value is None: + value = openai_config.get(key) + if value is None: + value = default + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise cento_build.BuildError(f"{key} must be an integer") from exc + if parsed <= 0: + raise cento_build.BuildError(f"{key} must be greater than zero") + return parsed + + +def write_zero_cost_receipt(worker_dir: Path, task: dict[str, Any], runtime: str, status: str) -> Path: + path = worker_dir / "cost_receipt.json" + write_json( + path, + { + "schema_version": "cento.api_worker_cost_receipt.v1", + "worker_id": str(task.get("worker_id") or task["id"]), + "task_id": task["id"], + "provider": runtime, + "cost_usd_estimate": 0.0, + "usage": {}, + "pricing": {}, + "estimate_method": "local_runtime_zero_cost", + "status": status, + "written_at": now_iso(), + }, + ) + return path + + +def write_blocked_worker_receipts( + worker_dir: Path, + task: dict[str, Any], + *, + runtime: str, + reason: str, + cost_estimate: float, +) -> tuple[Path, Path]: + worker_id = str(task.get("worker_id") or task["id"]) + worker_dir.mkdir(parents=True, exist_ok=True) + cost_path = worker_dir / "cost_receipt.json" + receipt_path = worker_dir / "worker_receipt.json" + written_at = now_iso() + write_json( + cost_path, + { + "schema_version": "cento.api_worker_cost_receipt.v1", + "worker_id": worker_id, + "task_id": task["id"], + "provider": runtime, + "cost_usd_estimate": 0.0, + "reserved_cost_usd_estimate": cost_estimate, + "usage": {}, + "pricing": {}, + "estimate_method": "not_dispatched_budget_blocked", + "status": "budget_blocked", + "written_at": written_at, + }, + ) + write_json( + receipt_path, + { + "schema_version": "cento.api_worker_receipt.v1", + "worker_id": worker_id, + "task_id": task["id"], + "status": "budget_blocked", + "request": None, + "response": None, + "artifact": None, + "cost_receipt": rel(cost_path), + "started_at": written_at, + "completed_at": written_at, + "errors": [reason], + }, + ) + return cost_path, receipt_path + + +def write_failed_api_worker_receipts( + worker_dir: Path, + task: dict[str, Any], + *, + reason: str, + cost_estimate: float, +) -> tuple[Path, Path]: + worker_id = str(task.get("worker_id") or task["id"]) + worker_dir.mkdir(parents=True, exist_ok=True) + cost_path = worker_dir / "cost_receipt.json" + receipt_path = worker_dir / "worker_receipt.json" + written_at = now_iso() + write_json( + cost_path, + { + "schema_version": "cento.api_worker_cost_receipt.v1", + "worker_id": worker_id, + "task_id": task["id"], + "provider": "openai", + "cost_usd_estimate": 0.0, + "reserved_cost_usd_estimate": cost_estimate, + "usage": {}, + "pricing": {}, + "estimate_method": "api_worker_failed_before_cost_receipt", + "status": "failed", + "written_at": written_at, + }, + ) + write_json( + receipt_path, + { + "schema_version": "cento.api_worker_receipt.v1", + "worker_id": worker_id, + "task_id": task["id"], + "status": "failed", + "request": rel(worker_dir / "request.json") if (worker_dir / "request.json").exists() else None, + "response": rel(worker_dir / "response.json") if (worker_dir / "response.json").exists() else None, + "artifact": rel(worker_dir / "artifact.json") if (worker_dir / "artifact.json").exists() else None, + "cost_receipt": rel(cost_path), + "started_at": written_at, + "completed_at": written_at, + "errors": [reason], + }, + ) + return cost_path, receipt_path + + +def read_context_snippets(paths: list[str], *, max_files: int = 8, max_bytes_per_file: int = 4000) -> list[dict[str, Any]]: + snippets: list[dict[str, Any]] = [] + for item in paths[:max_files]: + try: + normalized = cento_build.normalize_path(item) + except cento_build.BuildError: + continue + path = ROOT / normalized + if not path.is_file(): + continue + try: + data = path.read_bytes()[:max_bytes_per_file] + text = data.decode("utf-8", errors="replace") + except OSError: + continue + snippets.append({"path": normalized, "content": text, "truncated": path.stat().st_size > max_bytes_per_file}) + return snippets + + +def build_api_task_request( + workset: dict[str, Any], + task: dict[str, Any], + *, + run_id: str, + profile_name: str, + output_schema: str, +) -> dict[str, Any]: + read_paths = [*task.get("read_paths", []), *task.get("write_paths", [])] + return { + "schema_version": "cento.api_worker_request.v1", + "workset_id": workset["id"], + "run_id": run_id, + "task_id": task["id"], + "worker_id": str(task.get("worker_id") or task["id"]), + "task": task["task"], + "description": task["description"], + "depends_on": task["depends_on"], + "routes": task["routes"], + "write_paths": task["write_paths"], + "read_paths": task["read_paths"], + "api_profile": profile_name, + "output_schema": output_schema, + "artifact_type": cento_openai_worker.artifact_type_for_schema(output_schema), + "context_snippets": read_context_snippets(read_paths), + "rules": [ + "Return only the structured output requested by the schema.", + "Do not mutate repository files.", + "If proposing file contents, include complete UTF-8 content for owned paths only.", + ], + } + + +def run_api_task_worker( + workset: dict[str, Any], + task: dict[str, Any], + *, + run_id: str, + workset_dir: Path, + api_config: dict[str, Any], + api_config_path: Path, + profile_name: str, + output_schema: str, + cost_estimate: float, + max_input_chars: int, + max_output_tokens: int, + timeout: int | None, + retry_attempts: int | None, + validation_tier: str | None, + allow_dirty_owned: bool, +) -> dict[str, Any]: + manifest_path, manifest = make_task_manifest( + workset, + task, + run_id=run_id, + validation_tier=validation_tier, + allow_dirty_owned=allow_dirty_owned, + allow_creates=True, + ) + worker_dir = workset_dir / "workers" / str(task.get("worker_id") or task["id"]) + worker_dir.mkdir(parents=True, exist_ok=True) + task_request = build_api_task_request(workset, task, run_id=run_id, profile_name=profile_name, output_schema=output_schema) + task_request_path = worker_dir / "task_request.json" + write_json(task_request_path, task_request) + openai_config = api_openai_config(api_config) + command = [ + sys.executable, + str(ROOT / "scripts" / "cento_openai_worker.py"), + "run", + rel(task_request_path), + "--out-dir", + rel(worker_dir), + "--profile", + profile_name, + "--config", + rel(api_config_path), + "--output-schema", + output_schema, + "--worker-id", + str(task.get("worker_id") or task["id"]), + "--reserved-cost-usd", + f"{cost_estimate:.6f}", + "--json", + ] + effective_timeout = timeout or int(openai_config.get("timeout_seconds") or 45) + command.extend(["--timeout", str(effective_timeout)]) + effective_retries = retry_attempts if retry_attempts is not None else int(openai_config.get("retry_attempts") or 0) + command.extend(["--retry-attempts", str(effective_retries)]) + command.extend(["--max-input-chars", str(max_input_chars)]) + command.extend(["--max-output-tokens", str(max_output_tokens)]) + started = time.perf_counter() + try: + proc = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=effective_timeout * (effective_retries + 1) + 10, check=False) + except subprocess.TimeoutExpired as exc: + reason = f"api worker timed out after {exc.timeout} seconds" + cost_path, receipt_path = write_failed_api_worker_receipts(worker_dir, task, reason=reason, cost_estimate=cost_estimate) + return { + "task_id": task["id"], + "manifest": rel(manifest_path), + "build_id": manifest.get("id"), + "build_dir": rel(cento_build.build_dir_for_manifest(manifest, manifest_path)), + "api_worker_dir": rel(worker_dir), + "api_artifact": rel(worker_dir / "artifact.json") if (worker_dir / "artifact.json").exists() else None, + "api_cost_receipt": rel(cost_path), + "api_worker_receipt": rel(receipt_path), + "profile": profile_name, + "output_schema": output_schema, + "exit_code": None, + "stdout": exc.stdout or "", + "stderr": exc.stderr or "", + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + "status": "failed", + "errors": [reason], + } + duration_ms = round((time.perf_counter() - started) * 1000, 3) + artifact_path = worker_dir / "artifact.json" + cost_path = worker_dir / "cost_receipt.json" + worker_receipt_path = worker_dir / "worker_receipt.json" + result: dict[str, Any] = { + "task_id": task["id"], + "manifest": rel(manifest_path), + "build_id": manifest.get("id"), + "build_dir": rel(cento_build.build_dir_for_manifest(manifest, manifest_path)), + "api_worker_dir": rel(worker_dir), + "api_artifact": rel(artifact_path) if artifact_path.exists() else None, + "api_cost_receipt": rel(cost_path) if cost_path.exists() else None, + "api_worker_receipt": rel(worker_receipt_path) if worker_receipt_path.exists() else None, + "profile": profile_name, + "output_schema": output_schema, + "exit_code": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + "duration_ms": duration_ms, + } + if proc.returncode != 0: + errors: list[str] = [] + if artifact_path.exists(): + try: + artifact = read_json(artifact_path) + errors.extend([str(item) for item in artifact.get("errors") or []]) + except cento_build.BuildError: + pass + if not errors: + errors.append(proc.stderr.strip() or proc.stdout.strip() or "api worker failed") + result["status"] = "failed" + result["errors"] = errors + return result + + materialized = materialize_api_artifact( + artifact_path, + manifest_path=manifest_path, + allow_dirty_owned=allow_dirty_owned, + ) + result["status"] = "completed" if materialized.get("status") == "materialized" else "failed" + result["materialization_receipt"] = materialized.get("materialization_receipt") + result["worker"] = { + "status": "accepted" if materialized.get("status") == "materialized" else "rejected", + "worker_status": "completed" if materialized.get("status") == "materialized" else "failed", + "build_id": manifest.get("id"), + "worker_id": "builder_1", + "runtime": "api-openai-materializer", + "worker_dir": materialized.get("worker_dir"), + "worker_artifact": materialized.get("worker_artifact"), + "patch_bundle": materialized.get("patch_bundle"), + "patch": materialized.get("patch"), + "touched_paths": materialized.get("touched_paths") or [], + "errors": materialized.get("errors") or [], + } + if result["status"] != "completed": + result["errors"] = materialized.get("errors") or ["artifact materialization failed"] + return result + + +def artifact_content_entries(artifact: dict[str, Any]) -> list[dict[str, str]]: + content = artifact.get("content") + if not isinstance(content, dict): + raise cento_build.BuildError("artifact content must be an object") + entries: list[dict[str, str]] = [] + for key in ("owned_path_contents", "files", "file_changes"): + raw = content.get(key) + if not isinstance(raw, list): + continue + for item in raw: + if not isinstance(item, dict): + raise cento_build.BuildError(f"artifact content {key} entries must be objects") + path = item.get("path") + file_content = item.get("content") + if isinstance(path, str) and isinstance(file_content, str): + entries.append({"path": cento_build.normalize_path(path), "content": file_content}) + if entries: + return entries + owned_paths = [cento_build.normalize_path(str(item)) for item in artifact.get("owned_paths") or []] + if len(owned_paths) != 1: + raise cento_build.BuildError("artifact without owned_path_contents must own exactly one path") + return [{"path": owned_paths[0], "content": json.dumps(content, indent=2, sort_keys=False) + "\n"}] + + +def create_materialization_manifest( + artifact: dict[str, Any], + *, + build_id: str | None, + validation_tier: str | None, + allow_dirty_owned: bool, +) -> tuple[Path, dict[str, Any]]: + owned_paths = [cento_build.normalize_path(str(item)) for item in artifact.get("owned_paths") or []] + if not owned_paths: + raise cento_build.BuildError("artifact owned_paths must not be empty") + task_id = str(artifact.get("task_id") or artifact.get("worker_id") or "api_artifact") + build_args = argparse.Namespace( + task=f"Materialize API artifact {task_id}", + description=f"Materialize structured API worker artifact for {task_id}.", + mode="fast", + write=owned_paths, + read=[], + route=[], + protect=[], + validation=validation_tier, + id=build_id or f"api_artifact_{cento_build.slugify(task_id)}_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}", + allow_dirty_owned=allow_dirty_owned, + ) + manifest = cento_build.create_manifest(build_args) + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + manifest["policies"] = {**policies, "allow_creates": True} + build_dir = cento_build.BUILD_ROOT / str(manifest["id"]) + build_dir.mkdir(parents=True, exist_ok=True) + manifest_path = build_dir / "manifest.json" + write_json(manifest_path, manifest) + (build_dir / "builder.prompt.md").write_text(cento_build.render_builder_prompt(manifest), encoding="utf-8") + return manifest_path, manifest + + +def materialize_api_artifact( + artifact_path: Path, + *, + manifest_path: Path | None = None, + build_id: str | None = None, + validation_tier: str | None = None, + allow_dirty_owned: bool = False, +) -> dict[str, Any]: + if not artifact_path.is_absolute(): + artifact_path = ROOT / artifact_path + started_at = now_iso() + errors: list[str] = [] + warnings: list[str] = [] + worktree_path: Path | None = None + worktree_removed = False + try: + artifact = read_json(artifact_path) + artifact_errors = cento_openai_worker.validate_json_schema(artifact, cento_openai_worker.api_worker_artifact_schema()) + if artifact_errors: + raise cento_build.BuildError("api worker artifact schema validation failed: " + "; ".join(artifact_errors)) + if artifact.get("status") != "completed": + raise cento_build.BuildError("api worker artifact is not completed: " + "; ".join([str(item) for item in artifact.get("errors") or []])) + if manifest_path is None: + manifest_path, manifest = create_materialization_manifest( + artifact, + build_id=build_id, + validation_tier=validation_tier, + allow_dirty_owned=allow_dirty_owned, + ) + else: + if not manifest_path.is_absolute(): + manifest_path = ROOT / manifest_path + manifest = read_json(manifest_path) + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + if not policies.get("allow_creates"): + manifest["policies"] = {**policies, "allow_creates": True} + write_json(manifest_path, manifest) + build_dir = cento_build.build_dir_for_manifest(manifest, manifest_path) + worker_id = cento_build.artifact_worker_id(manifest) + worker_dir = cento_build.worker_artifact_dir(manifest, worker_id, build_dir) + worker_dir.mkdir(parents=True, exist_ok=True) + patch_path = worker_dir / "patch.diff" + bundle_path = worker_dir / "patch_bundle.json" + build_worker_artifact_path = worker_dir / "worker_artifact.json" + handoff_path = worker_dir / "handoff.md" + for stale in (patch_path, bundle_path, build_worker_artifact_path, handoff_path): + if stale.exists(): + stale.unlink() + + entries = artifact_content_entries(artifact) + artifact_owned = [cento_build.normalize_path(str(item)) for item in artifact.get("owned_paths") or []] + manifest_owned = cento_build.manifest_write_paths(manifest) + for entry in entries: + path = entry["path"] + if not cento_build.path_allowed(path, artifact_owned): + raise cento_build.BuildError(f"artifact wants unowned path: {path}") + if not cento_build.path_allowed(path, manifest_owned): + raise cento_build.BuildError(f"artifact path outside manifest scope: {path}") + current_base = cento_build.git_value(["rev-parse", "HEAD"], "HEAD") + source = manifest.get("source") if isinstance(manifest.get("source"), dict) else {} + base_ref = str(source.get("base_ref") or "HEAD") + worktree_ref = current_base if base_ref == "HEAD" else base_ref + worktree_path, worktree_result = cento_build.create_isolated_worktree(worktree_ref, f"{manifest.get('id')}-materializer") + if worktree_path is None: + detail = (str(worktree_result["stderr"]) or str(worktree_result["stdout"])).strip() + raise cento_build.BuildError("materializer worktree creation failed: " + detail) + for entry in entries: + target = worktree_path / entry["path"] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(entry["content"], encoding="utf-8") + entry_paths = [entry["path"] for entry in entries] + add_intent = cento_build.run(["git", "add", "-N", "--", *entry_paths], cwd=worktree_path, timeout=120) + if add_intent["exit_code"] != 0: + forced_add_intent = cento_build.run(["git", "add", "-N", "-f", "--", *entry_paths], cwd=worktree_path, timeout=120) + if forced_add_intent["exit_code"] == 0: + warnings.append("git add -N required -f for ignored owned path") + else: + warnings.append("git add -N failed: " + (str(add_intent["stderr"]) or str(add_intent["stdout"])).strip()) + diff_result = cento_build.run(["git", "diff", "--binary", "--", *entry_paths], cwd=worktree_path, timeout=120) + if diff_result["exit_code"] != 0: + raise cento_build.BuildError("materializer diff failed: " + (str(diff_result["stderr"]) or str(diff_result["stdout"])).strip()) + patch_text = str(diff_result["stdout"]) + patch_path.write_text(patch_text, encoding="utf-8") + if not patch_text.strip(): + raise cento_build.BuildError("artifact materializer produced no patch") + analysis = cento_build.analyze_patch(patch_path) + touched_paths = [str(item) for item in analysis.get("paths") or []] + protected_paths = cento_build.manifest_protected_paths(manifest) + policies = manifest.get("policies") if isinstance(manifest.get("policies"), dict) else {} + patch_errors = cento_build.patch_policy_rejections(analysis, manifest_owned, protected_paths, policies) + unowned_paths = [path for path in touched_paths if not cento_build.path_allowed(path, manifest_owned)] + protected_touched = [path for path in touched_paths if cento_build.path_is_protected(path, protected_paths)] + if patch_errors or unowned_paths or protected_touched: + detail = patch_errors + [f"unowned paths touched: {', '.join(unowned_paths)}"] if unowned_paths else patch_errors + if protected_touched: + detail.append("protected paths touched: " + ", ".join(protected_touched)) + raise cento_build.BuildError("; ".join(detail)) + bundle_path = cento_build.synthesize_patch_bundle( + manifest, + patch_path, + touched_paths, + build_dir, + out_path=bundle_path, + worker_id=worker_id, + summary=f"Materialized from API artifact {rel(artifact_path)}.", + ) + build_worker_artifact = { + "schema_version": cento_build.SCHEMA_WORKER_ARTIFACT, + "manifest_id": manifest.get("id"), + "manifest_path": rel(manifest_path), + "worker_id": worker_id, + "worker_type": "local", + "role": "builder", + "runtime": "api-openai-materializer", + "runtime_profile": None, + "fixture_case": None, + "status": "completed", + "base_ref": base_ref, + "artifact_dir": rel(worker_dir), + "patch_file": rel(patch_path), + "patch_path": rel(patch_path), + "patch_bundle": rel(bundle_path), + "handoff": rel(handoff_path), + "touched_paths": touched_paths, + "owned_paths": [path for path in touched_paths if cento_build.path_allowed(path, manifest_owned)], + "unowned_paths": [], + "protected_paths_touched": [], + "staged_paths": [], + "dirty_unrelated_paths": [], + "rejections": [], + "assumptions": ["Source content came from a structured API worker artifact."], + "validation": {"status": "not_run", "reason": "integration validates patch"}, + "risks": [], + "warnings": warnings, + "stdout_path": None, + "stderr_path": None, + "duration_ms": 0, + "runtime_limits": {}, + "runtime_result": {"status": "passed", "exit_code": 0}, + "launch_head": worktree_ref, + "worker_head": worktree_ref, + "started_at": started_at, + "completed_at": now_iso(), + } + write_json(build_worker_artifact_path, build_worker_artifact) + cento_build.write_worker_handoff(handoff_path, status="completed", runtime="api-openai-materializer", touched_paths=touched_paths, errors=[], warnings=warnings) + status = "materialized" + except cento_build.BuildError as exc: + status = "failed" + errors.append(str(exc)) + touched_paths = [] + build_dir = cento_build.BUILD_ROOT / (build_id or "api_artifact_failed") + worker_dir = build_dir / "workers" / "builder_1" + patch_path = worker_dir / "patch.diff" + bundle_path = worker_dir / "patch_bundle.json" + build_worker_artifact_path = worker_dir / "worker_artifact.json" + manifest_path = manifest_path + finally: + remove_result = cento_build.remove_isolated_worktree(worktree_path) + if remove_result is not None: + worktree_removed = remove_result["exit_code"] == 0 + if not worktree_removed: + warnings.append("materializer worktree cleanup failed: " + (str(remove_result["stderr"]) or str(remove_result["stdout"])).strip()) + + receipt = { + "schema_version": SCHEMA_MATERIALIZATION_RECEIPT, + "status": status, + "artifact": rel(artifact_path), + "manifest": rel(manifest_path) if manifest_path else None, + "worker_dir": rel(worker_dir), + "worker_artifact": rel(build_worker_artifact_path) if build_worker_artifact_path.exists() else None, + "patch": rel(patch_path) if patch_path.exists() else None, + "patch_bundle": rel(bundle_path) if bundle_path.exists() else None, + "touched_paths": touched_paths, + "errors": errors, + "warnings": warnings, + "worktree_removed": worktree_removed if worktree_path else None, + "started_at": started_at, + "completed_at": now_iso(), + } + build_dir.mkdir(parents=True, exist_ok=True) + receipt_path = build_dir / "materialization_receipt.json" + write_json(receipt_path, receipt) + receipt["materialization_receipt"] = rel(receipt_path) + return receipt + + +def run_integrate(manifest_path: Path, bundle_path: Path, *, allow_dirty_owned: bool) -> tuple[int, str, str]: + args = [ + sys.executable, + str(ROOT / "scripts" / "cento_build.py"), + "integrate", + rel(manifest_path), + "--bundle", + rel(bundle_path), + "--worktree", + "--dry-run", + ] + if allow_dirty_owned: + args.append("--allow-dirty-owned") + proc = subprocess.run(args, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + + +def write_receipt(workset_dir: Path, receipt: dict[str, Any]) -> Path: + path = workset_dir / "workset_receipt.json" + write_json(path, receipt) + return path + + +def final_status(task_records: dict[str, dict[str, Any]]) -> str: + if all(record.get("status") == "applied" for record in task_records.values()): + return "completed" + if any(record.get("status") in {"blocked", "rejected", "failed", "dependency_blocked"} for record in task_records.values()): + return "blocked" + return "review" + + +def run_workset(args: argparse.Namespace) -> dict[str, Any]: + workset_path = Path(args.workset) + if not workset_path.is_absolute(): + workset_path = ROOT / workset_path + source_workset = load_workset(workset_path) + validation = validate_workset(source_workset) + run_stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f") + workset_id = str(source_workset.get("id") or workset_path.stem) + run_id = f"{cento_build.slugify(workset_id)}_{run_stamp}" + workset_dir = WORKSET_ROOT / run_id + workset_dir.mkdir(parents=True, exist_ok=True) + append_event(workset_dir, "workset_started", {"workset_id": workset_id, "source": rel(workset_path)}) + + if validation["status"] != "passed": + receipt = { + "schema_version": SCHEMA_WORKSET_RECEIPT, + "workset_id": workset_id, + "run_id": run_id, + "status": "rejected", + "errors": validation["errors"], + "warnings": validation["warnings"], + "tasks": {}, + "started_at": now_iso(), + "completed_at": now_iso(), + } + receipt_path = write_receipt(workset_dir, receipt) + append_event(workset_dir, "workset_rejected", {"errors": validation["errors"]}) + return {"status": "rejected", "workset_receipt": rel(receipt_path), "errors": validation["errors"], "workset_dir": rel(workset_dir)} + + workset = normalize_workset(source_workset) + max_parallel = int(args.max_workers or source_workset.get("max_parallel") or 1) + if max_parallel <= 0: + raise cento_build.BuildError("--max-workers must be greater than zero") + max_parallel = min(max_parallel, int(source_workset.get("max_parallel") or max_parallel)) + apply_mode = args.apply + write_json(workset_dir / "workset.json", workset) + write_json( + workset_dir / "leases.json", + { + "workset_id": workset["id"], + "run_id": run_id, + "exclusive": True, + "leases": {task["id"]: task["write_paths"] for task in workset["tasks"]}, + "written_at": now_iso(), + }, + ) + + tasks_by_id = {task["id"]: task for task in workset["tasks"]} + pending = set(tasks_by_id) + running: dict[concurrent.futures.Future[dict[str, Any]], str] = {} + records: dict[str, dict[str, Any]] = { + tid: { + "id": tid, + "status": "pending", + "depends_on": tasks_by_id[tid]["depends_on"], + "write_paths": tasks_by_id[tid]["write_paths"], + "build_dir": None, + "manifest": None, + "worker_artifact": None, + "patch_bundle": None, + "integration_receipt": None, + "apply_receipt": None, + "validation_receipt": None, + "taskstream_evidence": None, + "changed_paths": [], + "errors": [], + } + for tid in tasks_by_id + } + completed_for_deps: set[str] = set() + blocked: set[str] = set() + changed_paths: list[str] = [] + + runtime_profile_name = args.runtime_profile + runtime = args.local_builder or "command" + if runtime_profile_name: + profile = cento_build.runtime_profile(runtime_profile_name) + runtime = str(profile.get("type") or runtime) + + started = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=max_parallel) as executor: + while pending or running: + made_progress = False + ready = sorted( + tid + for tid in pending + if all(dep in completed_for_deps for dep in tasks_by_id[tid]["depends_on"]) + and not any(dep in blocked for dep in tasks_by_id[tid]["depends_on"]) + ) + for tid in ready: + if len(running) >= max_parallel: + break + task = tasks_by_id[tid] + records[tid]["status"] = "running" + append_event(workset_dir, "task_dispatched", {"task_id": tid, "depends_on": task["depends_on"]}) + future = executor.submit( + run_task_worker, + workset, + task, + run_id=run_id, + runtime_profile=runtime_profile_name, + runtime=runtime, + fixture_case=args.fixture_case, + worker_timeout=args.worker_timeout, + validation_tier=args.validation, + allow_dirty_owned=args.allow_dirty_owned, + allow_unsafe_command=args.allow_unsafe_command, + command_template=args.command, + ) + running[future] = tid + pending.remove(tid) + made_progress = True + + if not running: + for tid in sorted(pending): + missing = [dep for dep in tasks_by_id[tid]["depends_on"] if dep not in completed_for_deps] + records[tid]["status"] = "dependency_blocked" + records[tid]["errors"].append("dependencies not completed: " + ", ".join(missing)) + blocked.add(tid) + append_event(workset_dir, "task_dependency_blocked", {"task_id": tid, "missing": missing}) + pending.clear() + break + + done, _not_done = concurrent.futures.wait(running, timeout=0.2, return_when=concurrent.futures.FIRST_COMPLETED) + if not done and made_progress: + continue + if not done: + continue + + for future in done: + tid = running.pop(future) + try: + worker_payload = future.result() + except Exception as exc: + records[tid]["status"] = "failed" + records[tid]["errors"].append(str(exc)) + blocked.add(tid) + append_event(workset_dir, "task_failed", {"task_id": tid, "error": str(exc)}) + continue + + worker = worker_payload["worker"] + records[tid].update( + { + "build_id": worker_payload.get("build_id"), + "build_dir": worker_payload.get("build_dir"), + "manifest": worker_payload.get("manifest"), + "worker_artifact": worker.get("worker_artifact"), + "patch_bundle": worker.get("patch_bundle"), + "patch": worker.get("patch"), + "changed_paths": worker.get("touched_paths") or [], + } + ) + append_event(workset_dir, "worker_completed", {"task_id": tid, "status": worker.get("status"), "worker_status": worker.get("worker_status")}) + if worker.get("status") != "accepted" or not worker.get("patch_bundle"): + records[tid]["status"] = "blocked" + records[tid]["errors"].extend([str(item) for item in worker.get("errors") or ["worker rejected"]]) + blocked.add(tid) + append_event(workset_dir, "task_blocked", {"task_id": tid, "reason": "worker rejected"}) + continue + + manifest_path = ROOT / str(worker_payload["manifest"]) + bundle_path = ROOT / str(worker["patch_bundle"]) + code, stdout, stderr = run_integrate(manifest_path, bundle_path, allow_dirty_owned=args.allow_dirty_owned) + build_dir = ROOT / str(worker_payload["build_dir"]) + integration_receipt = build_dir / "integration_receipt.json" + records[tid]["integration_receipt"] = rel(integration_receipt) if integration_receipt.exists() else None + append_event(workset_dir, "task_integration_completed", {"task_id": tid, "exit_code": code, "receipt": records[tid]["integration_receipt"]}) + if code != 0: + records[tid]["status"] = "blocked" + records[tid]["errors"].append(stderr or stdout or "integration rejected") + blocked.add(tid) + append_event(workset_dir, "task_blocked", {"task_id": tid, "reason": "integration rejected"}) + continue + + if apply_mode == "sequential": + apply_receipt = cento_build.apply_build_bundle( + manifest_path, + bundle_path, + integration_receipt, + allow_dirty_owned=args.allow_dirty_owned, + ) + records[tid]["apply_receipt"] = rel(build_dir / "apply_receipt.json") + records[tid]["validation_receipt"] = rel(build_dir / "validation_receipt.json") + records[tid]["taskstream_evidence"] = rel(build_dir / "taskstream_evidence.json") + if apply_receipt.get("status") != "applied": + records[tid]["status"] = "blocked" + records[tid]["errors"].extend([str(item) for item in apply_receipt.get("rejections") or ["apply rejected"]]) + blocked.add(tid) + append_event(workset_dir, "task_blocked", {"task_id": tid, "reason": "apply rejected"}) + continue + records[tid]["status"] = "applied" + completed_for_deps.add(tid) + changed_paths.extend([str(item) for item in apply_receipt.get("changed_paths") or []]) + append_event(workset_dir, "task_applied", {"task_id": tid, "apply_receipt": records[tid]["apply_receipt"]}) + else: + records[tid]["status"] = "accepted" + completed_for_deps.add(tid) + append_event(workset_dir, "task_accepted", {"task_id": tid, "integration_receipt": records[tid]["integration_receipt"]}) + + status = final_status(records) + receipt = { + "schema_version": SCHEMA_WORKSET_RECEIPT, + "workset_id": workset["id"], + "run_id": run_id, + "source": rel(workset_path), + "status": status, + "mode": workset["mode"], + "runtime_profile": runtime_profile_name, + "max_parallel": max_parallel, + "apply": apply_mode, + "integration": "sequential", + "no_shared_files": True, + "tasks": records, + "changed_paths": sorted(set(changed_paths)), + "events": rel(workset_dir / "events.ndjson"), + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + "written_at": now_iso(), + } + receipt_path = write_receipt(workset_dir, receipt) + append_event(workset_dir, "workset_completed", {"status": status, "receipt": rel(receipt_path)}) + write_json( + workset_dir / "workset_evidence.json", + { + "schema_version": "cento.workset_evidence.v1", + "workset_id": workset["id"], + "run_id": run_id, + "status": status, + "workset_receipt": rel(receipt_path), + "tasks": records, + "events": rel(workset_dir / "events.ndjson"), + "written_at": now_iso(), + }, + ) + return { + "status": status, + "workset_id": workset["id"], + "run_id": run_id, + "workset_dir": rel(workset_dir), + "workset_receipt": rel(receipt_path), + "task_statuses": {tid: record["status"] for tid, record in records.items()}, + "changed_paths": sorted(set(changed_paths)), + } + + +def task_records_summary(records: dict[str, dict[str, Any]]) -> dict[str, Any]: + completed = sorted(tid for tid, record in records.items() if record.get("status") in {"applied", "accepted"}) + blocked = sorted(tid for tid, record in records.items() if record.get("status") in {"blocked", "dependency_blocked", "budget_blocked", "budget_exceeded", "rejected"}) + failed = sorted(tid for tid, record in records.items() if record.get("status") == "failed") + return { + "completed_tasks": completed, + "blocked_tasks": blocked, + "failed_tasks": failed, + "completed_task_count": len(completed), + "blocked_task_count": len(blocked), + "failed_task_count": len(failed), + } + + +def run_workset_execute(args: argparse.Namespace) -> dict[str, Any]: + workset_path = Path(args.workset) + if not workset_path.is_absolute(): + workset_path = ROOT / workset_path + source_workset = load_workset(workset_path) + runtime = str(args.runtime) + allow_missing_paths = runtime == "api-openai" or bool(getattr(args, "allow_creates", False)) + validation = validate_workset(source_workset, allow_missing_write_paths=allow_missing_paths) + run_stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f") + workset_id = str(source_workset.get("id") or workset_path.stem) + run_id = f"{cento_build.slugify(workset_id)}_{run_stamp}" + workset_dir = WORKSET_ROOT / run_id + workset_dir.mkdir(parents=True, exist_ok=True) + append_event(workset_dir, "workset_execute_started", {"workset_id": workset_id, "source": rel(workset_path), "runtime": runtime}) + + def rejected_result(errors: list[str], warnings: list[str] | None = None) -> dict[str, Any]: + receipt = { + "schema_version": SCHEMA_WORKSET_RECEIPT, + "workset_id": workset_id, + "run_id": run_id, + "status": "rejected", + "errors": errors, + "warnings": warnings or [], + "total_tasks": 0, + "completed_tasks": [], + "blocked_tasks": [], + "failed_tasks": [], + "total_cost_usd": 0.0, + "target_budget_usd": args.budget_usd, + "max_budget_usd": args.max_budget_usd, + "elapsed_seconds": 0.0, + "workers": [], + "artifacts": [], + "patch_bundles": [], + "integration_receipts": [], + "validation_receipts": [], + "tasks": {}, + "started_at": now_iso(), + "completed_at": now_iso(), + } + receipt_path = write_receipt(workset_dir, receipt) + append_event(workset_dir, "workset_execute_rejected", {"errors": errors}) + return {"status": "rejected", "workset_receipt": rel(receipt_path), "errors": errors, "workset_dir": rel(workset_dir)} + + if validation["status"] != "passed": + return rejected_result(validation["errors"], validation["warnings"]) + + if args.integrate != "sequential": + raise cento_build.BuildError("workset execute v1 only supports --integrate sequential") + + api_config: dict[str, Any] = {} + api_config_path = Path(args.api_config) + if not api_config_path.is_absolute(): + api_config_path = ROOT / api_config_path + openai_config: dict[str, Any] = {} + if runtime == "api-openai": + api_config = load_api_worker_config(api_config_path) + openai_config = api_openai_config(api_config) + if openai_config.get("enabled") is False: + raise cento_build.BuildError("OpenAI API workers are disabled in .cento/api_workers.yaml") + + workset = normalize_workset(source_workset) + requested_parallel = int(args.max_parallel or source_workset.get("max_parallel") or 1) + if requested_parallel <= 0: + raise cento_build.BuildError("--max-parallel must be greater than zero") + workset_limit = int(source_workset.get("max_parallel") or requested_parallel) + max_parallel = min(requested_parallel, workset_limit) + if runtime == "api-openai" and openai_config.get("max_parallel_requests") is not None: + max_parallel = min(max_parallel, int(openai_config.get("max_parallel_requests") or max_parallel)) + + default_budget = float(openai_config.get("budget_usd_default") or 3.0) if runtime == "api-openai" else 0.0 + configured_max_budget = float(openai_config.get("budget_usd_max") or 5.0) if runtime == "api-openai" else 0.0 + default_max_budget = configured_max_budget + target_budget = float(args.budget_usd if args.budget_usd is not None else default_budget) + requested_max_budget = float(args.max_budget_usd if args.max_budget_usd is not None else default_max_budget) + if runtime == "api-openai" and requested_max_budget > configured_max_budget: + return rejected_result([f"--max-budget-usd {requested_max_budget:.4f} exceeds configured openai.budget_usd_max {configured_max_budget:.4f}"]) + max_budget = requested_max_budget + if runtime == "api-openai" and target_budget > max_budget: + return rejected_result(["--budget-usd cannot exceed --max-budget-usd"]) + if runtime == "api-openai" and max_budget <= 0: + return rejected_result(["--max-budget-usd must be greater than zero"]) + + apply_mode = "sequential" if args.apply else "none" + write_json(workset_dir / "workset.json", workset) + write_json( + workset_dir / "leases.json", + { + "workset_id": workset["id"], + "run_id": run_id, + "exclusive": True, + "leases": {task["id"]: task["write_paths"] for task in workset["tasks"]}, + "written_at": now_iso(), + }, + ) + + tasks_by_id = {task["id"]: task for task in workset["tasks"]} + pending = set(tasks_by_id) + running: dict[concurrent.futures.Future[dict[str, Any]], str] = {} + running_estimates: dict[str, float] = {} + records: dict[str, dict[str, Any]] = { + tid: { + "id": tid, + "worker_id": str(tasks_by_id[tid].get("worker_id") or tid), + "status": "pending", + "depends_on": tasks_by_id[tid]["depends_on"], + "write_paths": tasks_by_id[tid]["write_paths"], + "runtime": runtime, + "build_dir": None, + "manifest": None, + "api_worker_dir": None, + "api_artifact": None, + "api_cost_receipt": None, + "api_worker_receipt": None, + "worker_artifact": None, + "patch_bundle": None, + "integration_receipt": None, + "apply_receipt": None, + "validation_receipt": None, + "taskstream_evidence": None, + "changed_paths": [], + "cost_usd_estimate": 0.0, + "errors": [], + } + for tid in tasks_by_id + } + completed_for_deps: set[str] = set() + blocked: set[str] = set() + changed_paths: list[str] = [] + total_cost_usd = 0.0 + artifacts: list[str] = [] + patch_bundles: list[str] = [] + integration_receipts: list[str] = [] + validation_receipts: list[str] = [] + workers: list[str] = [] + hard_budget_exceeded = False + + started = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=max_parallel) as executor: + while pending or running: + made_progress = False + ready = sorted( + tid + for tid in pending + if all(dep in completed_for_deps for dep in tasks_by_id[tid]["depends_on"]) + and not any(dep in blocked for dep in tasks_by_id[tid]["depends_on"]) + ) + for tid in ready: + if len(running) >= max_parallel: + break + task = tasks_by_id[tid] + estimate = 0.0 + profile_name = "" + output_schema = "" + if runtime == "api-openai": + profile_name = api_profile_name_for_task(task, args.api_profile) + output_schema = api_output_schema_for_profile(api_config, profile_name, task) + estimate = api_cost_estimate(api_config, profile_name, task) + max_input_chars = api_positive_int_limit(api_config, profile_name, task, "max_input_chars", 20_000) + max_output_tokens = api_positive_int_limit(api_config, profile_name, task, "max_output_tokens", 2_000) + reserved = total_cost_usd + sum(running_estimates.values()) + if hard_budget_exceeded or estimate > max_budget or reserved + estimate > max_budget: + if hard_budget_exceeded: + reason = f"hard budget already exceeded: total={total_cost_usd:.4f} max={max_budget:.4f}" + else: + reason = f"budget cap would be exceeded: reserved={reserved:.4f} estimate={estimate:.4f} max={max_budget:.4f}" + worker_dir = workset_dir / "workers" / str(task.get("worker_id") or tid) + cost_path, worker_receipt_path = write_blocked_worker_receipts( + worker_dir, + task, + runtime=runtime, + reason=reason, + cost_estimate=estimate, + ) + records[tid]["status"] = "budget_blocked" + records[tid]["api_worker_dir"] = rel(worker_dir) + records[tid]["api_cost_receipt"] = rel(cost_path) + records[tid]["api_worker_receipt"] = rel(worker_receipt_path) + records[tid]["errors"].append(reason) + workers.extend([rel(cost_path), rel(worker_receipt_path)]) + blocked.add(tid) + pending.remove(tid) + append_event(workset_dir, "task_budget_blocked", {"task_id": tid, "estimate": estimate, "max_budget_usd": max_budget}) + made_progress = True + continue + records[tid]["status"] = "running" + records[tid]["cost_usd_estimate"] = estimate + append_event(workset_dir, "task_dispatched", {"task_id": tid, "depends_on": task["depends_on"], "runtime": runtime}) + if runtime == "api-openai": + future = executor.submit( + run_api_task_worker, + workset, + task, + run_id=run_id, + workset_dir=workset_dir, + api_config=api_config, + api_config_path=api_config_path, + profile_name=profile_name, + output_schema=output_schema, + cost_estimate=estimate, + max_input_chars=max_input_chars, + max_output_tokens=max_output_tokens, + timeout=args.worker_timeout, + retry_attempts=args.retry_attempts, + validation_tier=args.validation, + allow_dirty_owned=args.allow_dirty_owned, + ) + running_estimates[tid] = estimate + else: + local_runtime = "fixture" if runtime == "fixture" else "command" + runtime_profile = args.runtime_profile or "" + if runtime == "local-command" and not runtime_profile and not args.command: + raise cento_build.BuildError("local-command runtime requires --runtime-profile or --command") + future = executor.submit( + run_task_worker, + workset, + task, + run_id=run_id, + runtime_profile=runtime_profile, + runtime=local_runtime, + fixture_case=args.fixture_case, + worker_timeout=args.worker_timeout, + validation_tier=args.validation, + allow_dirty_owned=args.allow_dirty_owned, + allow_unsafe_command=args.allow_unsafe_command or bool(args.command), + command_template=args.command, + ) + running_estimates[tid] = 0.0 + running[future] = tid + pending.remove(tid) + made_progress = True + + if not running: + for tid in sorted(pending): + missing = [dep for dep in tasks_by_id[tid]["depends_on"] if dep not in completed_for_deps] + records[tid]["status"] = "dependency_blocked" + records[tid]["errors"].append("dependencies not completed: " + ", ".join(missing)) + blocked.add(tid) + append_event(workset_dir, "task_dependency_blocked", {"task_id": tid, "missing": missing}) + pending.clear() + break + + done, _not_done = concurrent.futures.wait(running, timeout=0.2, return_when=concurrent.futures.FIRST_COMPLETED) + if not done and made_progress: + continue + if not done: + continue + + for future in done: + tid = running.pop(future) + running_estimates.pop(tid, None) + try: + worker_payload = future.result() + except Exception as exc: + records[tid]["status"] = "failed" + records[tid]["errors"].append(str(exc)) + blocked.add(tid) + append_event(workset_dir, "task_failed", {"task_id": tid, "error": str(exc)}) + continue + + if runtime != "api-openai": + local_worker_dir = workset_dir / "workers" / str(tasks_by_id[tid].get("worker_id") or tid) + local_worker_dir.mkdir(parents=True, exist_ok=True) + cost_path = write_zero_cost_receipt(local_worker_dir, tasks_by_id[tid], runtime, str(worker_payload.get("worker", {}).get("status") or "completed")) + records[tid]["api_cost_receipt"] = rel(cost_path) + workers.append(rel(cost_path)) + else: + if worker_payload.get("api_cost_receipt"): + try: + cost_payload = read_json(ROOT / str(worker_payload["api_cost_receipt"])) + actual_cost = float(cost_payload.get("cost_usd_estimate") or 0.0) + except Exception: + actual_cost = 0.0 + total_cost_usd = round(total_cost_usd + actual_cost, 6) + if worker_payload.get("api_artifact"): + artifacts.append(str(worker_payload["api_artifact"])) + for key in ("api_worker_receipt", "api_cost_receipt"): + if worker_payload.get(key): + workers.append(str(worker_payload[key])) + if total_cost_usd > max_budget: + hard_budget_exceeded = True + reason = f"hard budget exceeded after worker usage: total={total_cost_usd:.6f} max={max_budget:.6f}" + records[tid].update( + { + "manifest": worker_payload.get("manifest"), + "build_id": worker_payload.get("build_id"), + "build_dir": worker_payload.get("build_dir"), + "api_worker_dir": worker_payload.get("api_worker_dir"), + "api_artifact": worker_payload.get("api_artifact"), + "api_cost_receipt": worker_payload.get("api_cost_receipt"), + "api_worker_receipt": worker_payload.get("api_worker_receipt"), + } + ) + records[tid]["status"] = "budget_exceeded" + records[tid]["errors"].append(reason) + blocked.add(tid) + append_event(workset_dir, "hard_budget_exceeded", {"task_id": tid, "total_cost_usd": total_cost_usd, "max_budget_usd": max_budget}) + continue + + if worker_payload.get("status") == "failed" and not worker_payload.get("worker"): + records[tid].update( + { + "manifest": worker_payload.get("manifest"), + "build_id": worker_payload.get("build_id"), + "build_dir": worker_payload.get("build_dir"), + "api_worker_dir": worker_payload.get("api_worker_dir"), + "api_artifact": worker_payload.get("api_artifact"), + "api_cost_receipt": worker_payload.get("api_cost_receipt"), + "api_worker_receipt": worker_payload.get("api_worker_receipt"), + } + ) + records[tid]["status"] = "failed" + records[tid]["errors"].extend([str(item) for item in worker_payload.get("errors") or ["worker failed"]]) + blocked.add(tid) + append_event(workset_dir, "task_failed", {"task_id": tid, "errors": records[tid]["errors"]}) + continue + + worker = worker_payload["worker"] + records[tid].update( + { + "build_id": worker_payload.get("build_id"), + "build_dir": worker_payload.get("build_dir"), + "manifest": worker_payload.get("manifest"), + "api_worker_dir": worker_payload.get("api_worker_dir"), + "api_artifact": worker_payload.get("api_artifact"), + "api_cost_receipt": worker_payload.get("api_cost_receipt") or records[tid].get("api_cost_receipt"), + "api_worker_receipt": worker_payload.get("api_worker_receipt"), + "worker_artifact": worker.get("worker_artifact"), + "patch_bundle": worker.get("patch_bundle"), + "patch": worker.get("patch"), + "changed_paths": worker.get("touched_paths") or [], + } + ) + for path_key in ("api_artifact", "worker_artifact"): + if records[tid].get(path_key): + artifacts.append(str(records[tid][path_key])) + if records[tid].get("patch_bundle"): + patch_bundles.append(str(records[tid]["patch_bundle"])) + append_event(workset_dir, "worker_completed", {"task_id": tid, "status": worker.get("status"), "worker_status": worker.get("worker_status")}) + if worker.get("status") != "accepted" or not worker.get("patch_bundle"): + records[tid]["status"] = "blocked" + records[tid]["errors"].extend([str(item) for item in worker.get("errors") or ["worker rejected"]]) + blocked.add(tid) + append_event(workset_dir, "task_blocked", {"task_id": tid, "reason": "worker rejected"}) + continue + + manifest_path = ROOT / str(worker_payload["manifest"]) + bundle_path = ROOT / str(worker["patch_bundle"]) + code, stdout, stderr = run_integrate(manifest_path, bundle_path, allow_dirty_owned=args.allow_dirty_owned) + build_dir = ROOT / str(worker_payload["build_dir"]) + integration_receipt = build_dir / "integration_receipt.json" + records[tid]["integration_receipt"] = rel(integration_receipt) if integration_receipt.exists() else None + if records[tid]["integration_receipt"]: + integration_receipts.append(str(records[tid]["integration_receipt"])) + validation_receipt = build_dir / "validation_receipt.json" + records[tid]["validation_receipt"] = rel(validation_receipt) if validation_receipt.exists() else None + if records[tid]["validation_receipt"]: + validation_receipts.append(str(records[tid]["validation_receipt"])) + append_event(workset_dir, "task_integration_completed", {"task_id": tid, "exit_code": code, "receipt": records[tid]["integration_receipt"]}) + if code != 0: + records[tid]["status"] = "blocked" + records[tid]["errors"].append(stderr or stdout or "integration rejected") + blocked.add(tid) + append_event(workset_dir, "task_blocked", {"task_id": tid, "reason": "integration rejected"}) + continue + + if apply_mode == "sequential": + apply_receipt = cento_build.apply_build_bundle( + manifest_path, + bundle_path, + integration_receipt, + allow_dirty_owned=args.allow_dirty_owned, + ) + records[tid]["apply_receipt"] = rel(build_dir / "apply_receipt.json") + records[tid]["validation_receipt"] = rel(build_dir / "validation_receipt.json") + records[tid]["taskstream_evidence"] = rel(build_dir / "taskstream_evidence.json") + if records[tid]["validation_receipt"] not in validation_receipts: + validation_receipts.append(str(records[tid]["validation_receipt"])) + if apply_receipt.get("status") != "applied": + records[tid]["status"] = "blocked" + records[tid]["errors"].extend([str(item) for item in apply_receipt.get("rejections") or ["apply rejected"]]) + blocked.add(tid) + append_event(workset_dir, "task_blocked", {"task_id": tid, "reason": "apply rejected"}) + continue + records[tid]["status"] = "applied" + completed_for_deps.add(tid) + changed_paths.extend([str(item) for item in apply_receipt.get("changed_paths") or []]) + append_event(workset_dir, "task_applied", {"task_id": tid, "apply_receipt": records[tid]["apply_receipt"]}) + else: + records[tid]["status"] = "accepted" + completed_for_deps.add(tid) + append_event(workset_dir, "task_accepted", {"task_id": tid, "integration_receipt": records[tid]["integration_receipt"]}) + + summary = task_records_summary(records) + status = "completed" if summary["completed_task_count"] == len(records) else ("failed" if summary["failed_task_count"] and not summary["completed_task_count"] else "blocked") + elapsed_seconds = round(time.perf_counter() - started, 3) + receipt = { + "schema_version": SCHEMA_WORKSET_RECEIPT, + "workset_id": workset["id"], + "run_id": run_id, + "source": rel(workset_path), + "status": status, + "mode": workset["mode"], + "runtime": runtime, + "runtime_profile": args.runtime_profile, + "max_parallel": max_parallel, + "integration": args.integrate, + "apply": apply_mode, + "path_policy": { + "allow_creates": bool(getattr(args, "allow_creates", False)), + "missing_write_paths_allowed": allow_missing_paths, + }, + "total_tasks": len(records), + **summary, + "total_cost_usd": round(total_cost_usd, 6), + "target_budget_usd": target_budget, + "max_budget_usd": max_budget, + "target_budget_exceeded": runtime == "api-openai" and total_cost_usd > target_budget, + "hard_budget_exceeded": hard_budget_exceeded, + "elapsed_seconds": elapsed_seconds, + "workers": sorted(set(workers)), + "artifacts": sorted(set(artifacts)), + "patch_bundles": sorted(set(patch_bundles)), + "integration_receipts": sorted(set(integration_receipts)), + "validation_receipts": sorted(set(validation_receipts)), + "no_shared_files": True, + "tasks": records, + "changed_paths": sorted(set(changed_paths)), + "events": rel(workset_dir / "events.ndjson"), + "written_at": now_iso(), + } + receipt_path = write_receipt(workset_dir, receipt) + append_event(workset_dir, "workset_execute_completed", {"status": status, "receipt": rel(receipt_path), "total_cost_usd": receipt["total_cost_usd"]}) + write_json( + workset_dir / "workset_evidence.json", + { + "schema_version": "cento.workset_evidence.v1", + "workset_id": workset["id"], + "run_id": run_id, + "status": status, + "workset_receipt": rel(receipt_path), + "tasks": records, + "events": rel(workset_dir / "events.ndjson"), + "written_at": now_iso(), + }, + ) + return { + "status": status, + "workset_id": workset["id"], + "run_id": run_id, + "workset_dir": rel(workset_dir), + "workset_receipt": rel(receipt_path), + "task_statuses": {tid: record["status"] for tid, record in records.items()}, + "total_cost_usd": receipt["total_cost_usd"], + "changed_paths": sorted(set(changed_paths)), + } + + +def command_check(args: argparse.Namespace) -> int: + try: + workset = load_workset(Path(args.workset)) + allow_creates = bool(args.allow_creates or args.runtime == "api-openai") + result = validate_workset(workset, allow_missing_write_paths=allow_creates) + result["path_policy"] = { + "runtime": args.runtime, + "allow_creates": allow_creates, + "missing_write_paths_allowed": allow_creates, + } + except cento_build.BuildError as exc: + result = {"status": "failed", "errors": [str(exc)], "warnings": []} + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"workset check: {result['status']}") + for error in result.get("errors") or []: + print(f"error: {error}", file=sys.stderr) + for warning in result.get("warnings") or []: + print(f"warning: {warning}", file=sys.stderr) + return 0 if result["status"] == "passed" else 1 + + +def command_run(args: argparse.Namespace) -> int: + try: + result = run_workset(args) + except cento_build.BuildError as exc: + print(f"cento workset run: {exc}", file=sys.stderr) + return 1 + if args.json: + print(json.dumps(result, indent=2)) + else: + print(result["workset_receipt"]) + print(f"status: {result['status']}") + for tid, status in result["task_statuses"].items(): + print(f"{tid}: {status}") + return 0 if result["status"] in {"completed", "review"} else 1 + + +def command_execute(args: argparse.Namespace) -> int: + try: + result = run_workset_execute(args) + except cento_build.BuildError as exc: + print(f"cento workset execute: {exc}", file=sys.stderr) + return 1 + if args.json: + print(json.dumps(result, indent=2)) + else: + print(result["workset_receipt"]) + print(f"status: {result['status']}") + print(f"total_cost_usd: {result.get('total_cost_usd', 0.0):.6f}") + for tid, status in result["task_statuses"].items(): + print(f"{tid}: {status}") + return 0 if result["status"] == "completed" else 1 + + +def command_materialize_artifact(args: argparse.Namespace) -> int: + try: + result = materialize_api_artifact( + Path(args.artifact), + manifest_path=Path(args.manifest) if args.manifest else None, + build_id=args.build_id, + validation_tier=args.validation, + allow_dirty_owned=args.allow_dirty_owned, + ) + except cento_build.BuildError as exc: + print(f"cento workset materialize-artifact: {exc}", file=sys.stderr) + return 1 + if args.json: + print(json.dumps(result, indent=2)) + else: + print(result["materialization_receipt"]) + if result.get("patch_bundle"): + print(result["patch_bundle"]) + for error in result.get("errors") or []: + print(f"error: {error}", file=sys.stderr) + return 0 if result["status"] == "materialized" else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="cento workset", + description="Run a minimal local N-worker workset with exclusive paths and sequential integration.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + check = sub.add_parser("check", help="Validate workset shape, dependencies, and exclusive write paths.") + check.add_argument("workset", help="workset.json path.") + check.add_argument("--runtime", choices=["api-openai", "fixture", "local-command"], default="", help="Declared runtime policy for path validation.") + check.add_argument("--allow-creates", action="store_true", help="Allow explicit owned write paths that do not exist yet.") + check.add_argument("--json", action="store_true", help="Print JSON result.") + check.set_defaults(func=command_check) + + run_cmd = sub.add_parser("run", help="Run ready workset tasks in parallel and integrate patches sequentially.") + run_cmd.add_argument("workset", help="workset.json path.") + run_cmd.add_argument("--max-workers", type=int, help="Maximum parallel local workers.") + run_cmd.add_argument("--runtime-profile", required=True, help="Named runtime profile from .cento/runtimes.yaml.") + run_cmd.add_argument("--local-builder", help="Optional runtime adapter fallback; runtime profile type is preferred.") + run_cmd.add_argument("--apply", choices=["sequential", "none"], default="sequential", help="Apply accepted patches sequentially or only dry-run integrate.") + run_cmd.add_argument("--validation", help="Validation tier for generated build manifests.") + run_cmd.add_argument("--worker-timeout", type=int, default=None, help="Worker timeout in seconds; runtime profiles can provide the default.") + run_cmd.add_argument("--fixture-case", default="valid", choices=["valid", "unowned", "protected", "delete", "lockfile", "binary"], help="Fixture case when using a fixture runtime.") + run_cmd.add_argument("--command", help="Unsafe raw command template for command runtime.") + run_cmd.add_argument("--allow-unsafe-command", action="store_true", help="Allow raw shell command runtime.") + run_cmd.add_argument("--allow-dirty-owned", action="store_true", help="Allow dirty owned paths; recorded by build receipts.") + run_cmd.add_argument("--json", action="store_true", help="Print JSON result.") + run_cmd.set_defaults(func=command_run) + + execute = sub.add_parser("execute", help="Run ready workset tasks in parallel, including structured API workers.") + execute.add_argument("workset", help="workset.json path.") + execute.add_argument("--max-parallel", "--max-workers", dest="max_parallel", type=int, help="Maximum parallel workers.") + execute.add_argument("--runtime", required=True, choices=["api-openai", "fixture", "local-command"], help="Worker runtime family.") + execute.add_argument("--runtime-profile", help="Named local runtime profile for local-command or fixture execution.") + execute.add_argument("--api-profile", default="api-section-worker", help="Default API worker profile from .cento/api_workers.yaml.") + execute.add_argument("--api-config", default=str(API_CONFIG_PATH), help="API worker config path.") + execute.add_argument("--budget-usd", type=float, default=None, help="Target API worker budget.") + execute.add_argument("--max-budget-usd", type=float, default=None, help="Hard API worker budget cap.") + execute.add_argument("--integrate", choices=["sequential"], default="sequential", help="Patch integration strategy.") + execute.add_argument("--apply", action="store_true", help="Apply accepted patches sequentially after integration.") + execute.add_argument("--validation", help="Validation tier for generated build manifests.") + execute.add_argument("--worker-timeout", type=int, default=None, help="Worker timeout in seconds.") + execute.add_argument("--retry-attempts", type=int, default=None, help="API retry attempts after the first request.") + execute.add_argument("--fixture-case", default="valid", choices=["valid", "unowned", "protected", "delete", "lockfile", "binary"], help="Fixture case for --runtime fixture.") + execute.add_argument("--command", help="Raw command template for --runtime local-command.") + execute.add_argument("--allow-unsafe-command", action="store_true", help="Allow raw shell command runtime.") + execute.add_argument("--allow-dirty-owned", action="store_true", help="Allow dirty owned paths; recorded by build receipts.") + execute.add_argument("--allow-creates", action="store_true", help="Allow explicit owned write paths that do not exist yet.") + execute.add_argument("--json", action="store_true", help="Print JSON result.") + execute.set_defaults(func=command_execute) + + materialize = sub.add_parser("materialize-artifact", help="Convert a structured API worker artifact into a local patch bundle.") + materialize.add_argument("artifact", help="artifact.json path.") + materialize.add_argument("--manifest", help="Existing build manifest to materialize against.") + materialize.add_argument("--build-id", help="Build id when creating a materialization manifest.") + materialize.add_argument("--validation", help="Validation tier when creating a materialization manifest.") + materialize.add_argument("--allow-dirty-owned", action="store_true", help="Allow dirty owned paths; recorded by build receipts.") + materialize.add_argument("--json", action="store_true", help="Print JSON result.") + materialize.set_defaults(func=command_materialize_artifact) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/claude_chores.py b/scripts/claude_chores.py new file mode 100644 index 0000000..a1fbee2 --- /dev/null +++ b/scripts/claude_chores.py @@ -0,0 +1,950 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(os.environ.get("CENTO_ROOT", Path(__file__).resolve().parent.parent)) +RUN_ROOT = ROOT / "workspace" / "runs" / "claude-chores" +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "cento" +DOC_PATH = ROOT / "docs" / "claude-code-chores.md" +TOOLS_JSON = ROOT / "data" / "tools.json" +DEFAULT_PACKAGE = "claude-chores" +DEFAULT_RUNTIME = "claude-code" +DEFAULT_MODEL = "claude-sonnet-4-6" +DEFAULT_ACTIVE_TARGETS = {"builder": 2, "small": 1, "validator": 1, "coordinator": 0} +CRON_BEGIN = "# >>> cento claude-chores >>>" +CRON_END = "# <<< cento claude-chores <<<" +SCAN_ROOTS = ("scripts", "docs", "tests", "data") +TODO_PATTERN = re.compile(r"\b(?:TODO|FIXME)\s*:") + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def run_id() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT.resolve()).as_posix() + except ValueError: + return path.as_posix() + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return slug[:80] or "chore" + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def write_json(path: Path, payload: dict[str, Any] | list[Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8") + + +def command_result_payload(result: subprocess.CompletedProcess[str]) -> dict[str, Any]: + return { + "returncode": result.returncode, + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + } + + +def run_command(command: list[str], *, env: dict[str, str] | None = None, timeout: int = 90) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + + +def run_json_command(command: list[str], *, timeout: int = 45) -> dict[str, Any]: + result = run_command(command, timeout=timeout) + if result.returncode != 0: + return {"error": result.stderr.strip() or result.stdout.strip(), "returncode": result.returncode} + try: + payload = json.loads(result.stdout or "{}") + except json.JSONDecodeError as exc: + return {"error": f"invalid JSON: {exc}", "returncode": result.returncode, "stdout": result.stdout.strip()} + return payload if isinstance(payload, dict) else {} + + +def agent_work_issues() -> list[dict[str, Any]]: + payload = run_json_command(["python3", "scripts/agent_work.py", "list", "--json"], timeout=30) + issues = payload.get("issues") + return issues if isinstance(issues, list) else [] + + +def agent_work_active_runs(*, include_untracked: bool = True) -> list[dict[str, Any]]: + command = ["python3", "scripts/agent_work.py", "runs", "--json", "--active"] + if not include_untracked: + command.append("--no-untracked") + payload = run_json_command(command, timeout=20) + runs = payload.get("runs") + return runs if isinstance(runs, list) else [] + + +def candidate_fingerprint(source: str, title: str, owned_paths: list[str]) -> str: + seed = json.dumps({"source": source, "title": title, "owned_paths": sorted(owned_paths)}, sort_keys=True) + return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12] + + +def make_candidate( + *, + source: str, + title: str, + description: str, + owned_paths: list[str], + acceptance: list[str], + validation_commands: list[str], + priority: int, +) -> dict[str, Any]: + paths = [path for path in dict.fromkeys(owned_paths) if path] + fingerprint = candidate_fingerprint(source, title, paths) + return { + "fingerprint": fingerprint, + "source": source, + "title": title, + "task_title": f"[chore:{fingerprint}] {title}", + "description": description, + "owned_paths": paths, + "acceptance": acceptance, + "validation_commands": validation_commands, + "priority": priority, + "package": DEFAULT_PACKAGE, + "node": "linux", + "agent": "claude-code", + "role": "builder", + } + + +def load_tools() -> list[dict[str, Any]]: + payload = read_json(TOOLS_JSON) + tools = payload.get("tools") + return tools if isinstance(tools, list) else [] + + +def discover_missing_entrypoint_chores() -> list[dict[str, Any]]: + chores: list[dict[str, Any]] = [] + for tool in load_tools(): + entrypoint = str(tool.get("entrypoint") or "").strip() + if not entrypoint or entrypoint.startswith("~"): + continue + entry_path = ROOT / entrypoint.removeprefix("./") + if entry_path.exists(): + continue + tool_id = str(tool.get("id") or "unknown") + name = str(tool.get("name") or tool_id) + chores.append( + make_candidate( + source=f"missing-entrypoint:{tool_id}", + title=f"Restore registered {tool_id} entrypoint", + description=( + f"The Cento registry declares `{entrypoint}` for `{tool_id}` ({name}), " + "but the file is missing. Restore the entrypoint or correct the registry/docs so " + "the command no longer fails at dispatch time." + ), + owned_paths=[rel(entry_path), "data/tools.json", "docs/tool-index.md"], + acceptance=[ + f"`cento {tool_id} --help` or an equivalent smoke path no longer fails because the entrypoint is missing.", + "Registry and docs describe the actual command surface.", + ], + validation_commands=[ + f"test -e {shlex.quote(rel(entry_path))}", + f"./scripts/cento.sh docs {shlex.quote(tool_id)} >/tmp/cento-{slugify(tool_id)}-docs.txt", + ], + priority=10, + ) + ) + return chores + + +def text_files_under(root_name: str) -> list[Path]: + root = ROOT / root_name + if not root.exists(): + return [] + paths: list[Path] = [] + for path in root.rglob("*"): + if not path.is_file(): + continue + if any(part in {".git", "node_modules", "__pycache__", ".pytest_cache"} for part in path.parts): + continue + if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".sqlite3", ".db", ".pyc"}: + continue + paths.append(path) + return paths + + +def matching_files(pattern: str, *, roots: tuple[str, ...]) -> list[Path]: + matches: list[Path] = [] + for root_name in roots: + for path in text_files_under(root_name): + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if pattern in text: + matches.append(path) + return matches + + +def discover_docs_cli_drift_chores() -> list[dict[str, Any]]: + paths = [rel(path) for path in matching_files("dispatch-pool", roots=("data", "docs"))] + if not paths: + return [] + return [ + make_candidate( + source="docs-cli-drift:dispatch-pool", + title="Remove stale agent-work dispatch-pool references", + description=( + "`agent-work` no longer exposes a `dispatch-pool` subcommand, but docs or registry " + "text still references it. Replace stale references with the current `agent-pool-kick` " + "or `agent-work dispatch` surface." + ), + owned_paths=paths, + acceptance=[ + "No command-reference docs claim `cento agent-work dispatch-pool` is available.", + "Replacement text points operators to a working native Cento dispatch path.", + ], + validation_commands=[ + "! rg -n 'agent-work dispatch-pool|dispatch-pool' data docs", + "python3 -m json.tool data/tools.json >/tmp/cento-tools-json-check.txt", + ], + priority=20, + ) + ] + + +def discover_todo_chores(limit: int = 5) -> list[dict[str, Any]]: + chores: list[dict[str, Any]] = [] + for root_name in SCAN_ROOTS: + for path in text_files_under(root_name): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + continue + hits = [line.strip() for line in lines if TODO_PATTERN.search(line)] + if not hits: + continue + path_text = rel(path) + chores.append( + make_candidate( + source=f"todo-hotspot:{path_text}", + title=f"Resolve TODO/FIXME hotspot in {path_text}", + description=( + f"`{path_text}` contains {len(hits)} TODO/FIXME marker(s). Resolve the stale marker, " + "convert it into a clearer tracked issue, or document why it must remain." + ), + owned_paths=[path_text], + acceptance=[ + "The selected TODO/FIXME marker is resolved, clarified, or converted into explicit tracked follow-up.", + "The file remains syntactically valid for its format.", + ], + validation_commands=[f"test -s {shlex.quote(path_text)}"], + priority=60 + len(chores), + ) + ) + if len(chores) >= limit: + return chores + return chores + + +def discover_blocked_queue_chores(issues: list[dict[str, Any]], limit: int = 3) -> list[dict[str, Any]]: + chores: list[dict[str, Any]] = [] + for issue in issues: + if str(issue.get("status") or "") != "Blocked": + continue + issue_id = int(issue.get("id") or 0) + if issue_id <= 0: + continue + title = str(issue.get("subject") or f"issue {issue_id}") + chores.append( + make_candidate( + source=f"blocked-queue:{issue_id}", + title=f"Repair blocked Taskstream issue {issue_id}", + description=( + f"Review blocked Taskstream issue #{issue_id}: {title}. Identify whether it needs a " + "manifest repair, clearer owned paths, requeue note, or closure recommendation." + ), + owned_paths=[f"workspace/runs/agent-work/{issue_id}/"], + acceptance=[ + "A concise repair or closure recommendation is written into the issue/run evidence.", + "No unrelated Taskstream issues are modified.", + ], + validation_commands=[f"test -d workspace/runs/agent-work/{issue_id} || true"], + priority=80 + len(chores), + ) + ) + if len(chores) >= limit: + break + return chores + + +def discover_candidate_chores(scope: str, issues: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + issues = issues if issues is not None else agent_work_issues() + candidates: list[dict[str, Any]] = [] + candidates.extend(discover_missing_entrypoint_chores()) + candidates.extend(discover_docs_cli_drift_chores()) + if scope == "broad-repo": + candidates.extend(discover_todo_chores()) + candidates.extend(discover_blocked_queue_chores(issues)) + unique: dict[str, dict[str, Any]] = {} + for item in candidates: + unique.setdefault(str(item["fingerprint"]), item) + return sorted(unique.values(), key=lambda item: (int(item.get("priority") or 999), str(item.get("title") or ""))) + + +def open_chore_fingerprints(issues: list[dict[str, Any]]) -> dict[str, int]: + fingerprints: dict[str, int] = {} + for issue in issues: + if str(issue.get("package") or "") != DEFAULT_PACKAGE: + continue + status = str(issue.get("status") or "") + if status == "Done": + continue + subject = str(issue.get("subject") or "") + match = re.search(r"\[chore:([0-9a-f]{12})\]", subject) + if match: + try: + fingerprints[match.group(1)] = int(issue.get("id") or 0) + except (TypeError, ValueError): + fingerprints[match.group(1)] = 0 + return fingerprints + + +def annotate_existing(candidates: list[dict[str, Any]], issues: list[dict[str, Any]]) -> list[dict[str, Any]]: + existing = open_chore_fingerprints(issues) + annotated: list[dict[str, Any]] = [] + for item in candidates: + clone = dict(item) + issue_id = existing.get(str(item.get("fingerprint") or "")) + clone["existing_issue_id"] = issue_id or None + clone["eligible_to_create"] = issue_id is None + annotated.append(clone) + return annotated + + +def process_benefit_summary(active_runs: list[dict[str, Any]], issues: list[dict[str, Any]]) -> dict[str, Any]: + untracked = [run for run in active_runs if str(run.get("run_id") or "").startswith("untracked-")] + managed = [run for run in active_runs if run not in untracked] + blocked = [issue for issue in issues if str(issue.get("status") or "") == "Blocked"] + queued = [issue for issue in issues if str(issue.get("status") or "") == "Queued"] + return { + "active_run_count": len(active_runs), + "managed_active_run_count": len(managed), + "untracked_active_process_count": len(untracked), + "queued_issue_count": len(queued), + "blocked_issue_count": len(blocked), + "benefits": [ + "keeps small repo-maintenance work moving without consuming metered OpenAI API budget", + "turns discovered weak spots into scoped Taskstream items with validation manifests", + "reduces manual queue grooming before Codex or Claude workers pick up larger work", + ], + } + + +def render_markdown( + *, + generated_at: str, + scope: str, + candidates: list[dict[str, Any]], + created: list[dict[str, Any]], + dispatch_summary: dict[str, Any], + process_summary: dict[str, Any], +) -> str: + lines = [ + "# Claude Code Chores", + "", + f"Generated: `{generated_at}`", + "", + "## Policy", + "", + "- Runtime: `claude-code`.", + f"- Default model: `{DEFAULT_MODEL}`.", + "- Controlled cron cadence: every 30 minutes.", + "- Per tick: create at most 2 new chores and launch at most 2 Claude jobs.", + "- Active targets: `builder=2`, `small=1`, `validator=1`, `coordinator=0`.", + "- If Codex/Claude utilization is above 30%, prefer agent lanes for roughly 70-80% of eligible non-API-only work.", + "- Metered OpenAI API work is not used by this chore loop.", + "", + "## Process Benefit Scan", + "", + f"- Active runs/processes: `{process_summary.get('active_run_count', 0)}`.", + f"- Managed active runs: `{process_summary.get('managed_active_run_count', 0)}`.", + f"- Untracked active Codex/Claude processes: `{process_summary.get('untracked_active_process_count', 0)}`.", + f"- Queued issues: `{process_summary.get('queued_issue_count', 0)}`.", + f"- Blocked issues: `{process_summary.get('blocked_issue_count', 0)}`.", + "", + ] + for benefit in process_summary.get("benefits") or []: + lines.append(f"- {benefit}") + lines.extend(["", f"## Candidate Chores (`{scope}`)", ""]) + if not candidates: + lines.append("- No candidate chores found.") + else: + for item in candidates: + status = "existing" if item.get("existing_issue_id") else "new" + lines.append(f"- `{item['fingerprint']}` {item['title']} ({status})") + if item.get("owned_paths"): + lines.append(f" Owned paths: `{', '.join(item['owned_paths'])}`") + lines.extend(["", "## Created This Run", ""]) + if not created: + lines.append("- None.") + else: + for item in created: + issue_id = item.get("issue_id") or item.get("id") or "unknown" + lines.append(f"- `#{issue_id}` {item.get('title') or item.get('subject') or ''}") + lines.extend(["", "## Dispatch", ""]) + if dispatch_summary: + lines.append(f"- Status: `{dispatch_summary.get('status', 'unknown')}`.") + if dispatch_summary.get("agent_pool"): + pool = dispatch_summary["agent_pool"] + lines.append(f"- Pool return code: `{pool.get('returncode')}`.") + if pool.get("payload", {}).get("reason_summary"): + reason = pool["payload"]["reason_summary"] + lines.append(f"- Pool reason: `{reason.get('primary_reason', 'unknown')}`.") + else: + lines.append("- Not run.") + return "\n".join(lines) + "\n" + + +def mirror_latest(run_dir: Path) -> None: + latest = RUN_ROOT / "latest" + if latest.exists() or latest.is_symlink(): + if latest.is_dir() and not latest.is_symlink(): + shutil.rmtree(latest) + else: + latest.unlink() + shutil.copytree(run_dir, latest) + + +def resolve_run_dir(value: str = "", *, create: bool) -> Path: + run_dir = Path(value) if value else RUN_ROOT / run_id() + if not run_dir.is_absolute(): + run_dir = ROOT / run_dir + if create: + run_dir.mkdir(parents=True, exist_ok=True) + return run_dir + + +def write_run_artifacts( + *, + run_dir: Path, + generated_at: str, + scope: str, + candidates: list[dict[str, Any]], + created: list[dict[str, Any]], + dispatch_summary: dict[str, Any], + process_summary: dict[str, Any], +) -> dict[str, Any]: + markdown = render_markdown( + generated_at=generated_at, + scope=scope, + candidates=candidates, + created=created, + dispatch_summary=dispatch_summary, + process_summary=process_summary, + ) + paths = { + "candidate_chores": run_dir / "candidate_chores.json", + "created_issues": run_dir / "created_issues.json", + "dispatch_summary": run_dir / "dispatch_summary.json", + "markdown": run_dir / "claude-code-chores.md", + "status": run_dir / "status.json", + } + write_json(paths["candidate_chores"], candidates) + write_json(paths["created_issues"], created) + write_json(paths["dispatch_summary"], dispatch_summary) + paths["markdown"].write_text(markdown, encoding="utf-8") + status = { + "generated_at": generated_at, + "run_dir": rel(run_dir), + "scope": scope, + "candidate_count": len(candidates), + "new_candidate_count": len([item for item in candidates if item.get("eligible_to_create")]), + "created_count": len(created), + "dispatch_status": dispatch_summary.get("status", "not_run") if dispatch_summary else "not_run", + "artifacts": {name: rel(path) for name, path in paths.items() if name != "status"}, + "process_summary": process_summary, + } + write_json(paths["status"], status) + mirror_latest(run_dir) + return status + + +def build_story(candidate: dict[str, Any], draft_dir: Path) -> dict[str, Any]: + validation_path = rel(draft_dir / "validation.json") + run_dir = "workspace/runs/agent-work/0" + output_path = f"{run_dir}/worker-handoff.md" + return { + "schema_version": "1.0", + "issue": {"id": 0, "title": candidate["task_title"], "package": DEFAULT_PACKAGE}, + "lane": {"owner": "claude-chores", "node": "linux", "agent": "claude-code", "role": "builder"}, + "paths": {"run_dir": run_dir}, + "scope": {"goal": candidate["description"], "acceptance": candidate["acceptance"]}, + "expected_outputs": [ + { + "path": output_path, + "description": "Worker handoff summarizing delivered changes, validation, evidence, and residual risk.", + "owner": "claude-chores", + "required": True, + } + ], + "validation": { + "manifest": validation_path, + "mode": "no-model", + "no_model_eligible": True, + "risk": "medium", + "escalation_triggers": ["missing_manifest", "failed_deterministic_command", "ambiguity"], + "commands": candidate["validation_commands"], + }, + "deliverables": { + "manifest": f"{run_dir}/deliverables.json", + "hub": f"{run_dir}/start-here.html", + }, + "review_gate": { + "required_sections": ["Delivered", "Validation", "Evidence", "Residual risk"], + "residual_risk_required": True, + }, + "metadata": { + "drafted_at": now_iso(), + "source": "claude-chores", + "fingerprint": candidate["fingerprint"], + "owned_paths": candidate["owned_paths"], + }, + } + + +def build_validation(story: dict[str, Any], story_path: Path, candidate: dict[str, Any]) -> dict[str, Any]: + checks = [ + { + "name": "worker-handoff-exists", + "type": "file_exists", + "path": "workspace/runs/agent-work/{issue}/worker-handoff.md", + "required": True, + } + ] + checks.extend( + { + "name": f"command-{index}", + "type": "command", + "command": command, + "cwd": ".", + "timeout_seconds": 60, + "expect_exit": 0, + "required": True, + } + for index, command in enumerate(candidate.get("validation_commands") or [], start=1) + ) + return { + "schema": "cento.validation-manifest.v1", + "task": str(story["issue"]["title"]), + "story_manifest": rel(story_path), + "claim": str(story["scope"]["goal"]), + "risk": "medium", + "decision_requested": "approve", + "checks": checks, + "manual_review": [], + "coverage": { + "deterministic_checks": len(checks), + "manual_review_items": 0, + "automation_coverage_percent": 100.0, + }, + "stats_policy": { + "ai_calls_used": 0, + "estimated_ai_cost": 0, + "requires_total_duration_ms": True, + "requires_per_check_duration_ms": True, + }, + "created_at": now_iso(), + } + + +def update_canonical_validation(issue_id: int, candidate: dict[str, Any], source_story: dict[str, Any]) -> dict[str, str]: + story_path = ROOT / "workspace" / "runs" / "agent-work" / str(issue_id) / "story.json" + validation_path = story_path.with_name("validation.json") + story = read_json(story_path) or source_story + story.setdefault("issue", {})["id"] = issue_id + story["issue"]["title"] = candidate["task_title"] + story["issue"]["package"] = DEFAULT_PACKAGE + story.setdefault("paths", {})["run_dir"] = rel(story_path.parent) + story.setdefault("validation", {})["manifest"] = rel(validation_path) + story.setdefault("deliverables", {})["manifest"] = rel(story_path.parent / "deliverables.json") + story.setdefault("deliverables", {})["hub"] = rel(story_path.parent / "start-here.html") + expected_outputs = story.get("expected_outputs") if isinstance(story.get("expected_outputs"), list) else [] + for item in expected_outputs: + if isinstance(item, dict) and str(item.get("path") or "").endswith("worker-handoff.md"): + item["path"] = rel(story_path.parent / "worker-handoff.md") + story_path.parent.mkdir(parents=True, exist_ok=True) + story_path.write_text(json.dumps(story, indent=2, sort_keys=True) + "\n", encoding="utf-8") + validation = build_validation(story, story_path, candidate) + validation["story_manifest"] = rel(story_path) + for check in validation["checks"]: + if isinstance(check, dict) and str(check.get("path") or "").endswith("worker-handoff.md"): + check["path"] = rel(story_path.parent / "worker-handoff.md") + write_json(validation_path, validation) + return {"story_manifest": rel(story_path), "validation_manifest": rel(validation_path)} + + +def create_agent_work_issue(candidate: dict[str, Any], run_dir: Path, *, dry_run: bool) -> dict[str, Any]: + draft_dir = run_dir / "drafts" / candidate["fingerprint"] + draft_dir.mkdir(parents=True, exist_ok=True) + story_path = draft_dir / "story.json" + validation_path = draft_dir / "validation.json" + story = build_story(candidate, draft_dir) + validation = build_validation(story, story_path, candidate) + write_json(story_path, story) + write_json(validation_path, validation) + command = [ + "python3", + "scripts/agent_work.py", + "create", + "--title", + candidate["task_title"], + "--manifest", + rel(story_path), + "--description", + candidate["description"], + "--node", + candidate["node"], + "--agent", + candidate["agent"], + "--role", + candidate["role"], + "--package", + DEFAULT_PACKAGE, + "--json", + ] + for owned in candidate.get("owned_paths") or []: + command.extend(["--owns", str(owned)]) + record = { + "fingerprint": candidate["fingerprint"], + "title": candidate["task_title"], + "dry_run": dry_run, + "draft_story_manifest": rel(story_path), + "draft_validation_manifest": rel(validation_path), + "command": shlex.join(command), + } + if dry_run: + record["status"] = "planned" + return record + result = run_command(command, timeout=60) + record.update(command_result_payload(result)) + if result.returncode != 0: + record["status"] = "failed" + return record + try: + issue = json.loads(result.stdout or "{}") + except json.JSONDecodeError: + issue = {} + issue_id = int(issue.get("id") or 0) + record["issue_id"] = issue_id + record["issue"] = issue + if issue_id > 0: + record.update(update_canonical_validation(issue_id, candidate, story)) + record["status"] = "created" + else: + record["status"] = "failed" + record["stderr"] = record.get("stderr") or "agent-work create did not return an issue id" + return record + + +def run_pool(args: argparse.Namespace, *, dry_run: bool) -> dict[str, Any]: + command = [ + "./scripts/cento.sh", + "agent-pool-kick", + "--package", + DEFAULT_PACKAGE, + "--builder-target", + str(args.builder_target), + "--small-target", + str(args.small_target), + "--validator-target", + str(args.validator_target), + "--coordinator-target", + str(args.coordinator_target), + "--max-launch", + str(args.max_launch), + "--runtime", + args.runtime, + "--model", + args.model, + ] + if dry_run: + command.append("--dry-run") + env = os.environ.copy() + env["CENTO_AGENT_RUNTIME"] = args.runtime + env["CENTO_POOL_CLAUDE_MODEL"] = args.model + result = run_command(command, env=env, timeout=120) + payload: dict[str, Any] = {} + if result.stdout.strip(): + try: + parsed = json.loads(result.stdout) + if isinstance(parsed, dict): + payload = parsed + except json.JSONDecodeError: + payload = {} + return { + "status": "dry_run" if dry_run else ("completed" if result.returncode == 0 else "failed"), + "command": shlex.join(command), + "agent_pool": {**command_result_payload(result), "payload": payload}, + } + + +def command_plan(args: argparse.Namespace) -> int: + generated_at = now_iso() + run_dir = resolve_run_dir(args.run_dir, create=True) + issues = agent_work_issues() + active_runs = agent_work_active_runs(include_untracked=True) + candidates = annotate_existing(discover_candidate_chores(args.scope, issues), issues) + process_summary = process_benefit_summary(active_runs, issues) + status = write_run_artifacts( + run_dir=run_dir, + generated_at=generated_at, + scope=args.scope, + candidates=candidates, + created=[], + dispatch_summary={}, + process_summary=process_summary, + ) + print(json.dumps(status, indent=2, sort_keys=True) if args.json else f"planned {rel(run_dir)}") + return 0 + + +def command_run(args: argparse.Namespace) -> int: + generated_at = now_iso() + run_dir = resolve_run_dir(args.run_dir, create=True) + issues = agent_work_issues() + active_runs = agent_work_active_runs(include_untracked=True) + candidates = annotate_existing(discover_candidate_chores(args.scope, issues), issues) + eligible = [item for item in candidates if item.get("eligible_to_create")][: max(0, args.chore_limit)] + created = [create_agent_work_issue(item, run_dir, dry_run=args.dry_run) for item in eligible] + dispatch_summary = run_pool(args, dry_run=args.dry_run) + process_summary = process_benefit_summary(active_runs, issues) + status = write_run_artifacts( + run_dir=run_dir, + generated_at=generated_at, + scope=args.scope, + candidates=candidates, + created=created, + dispatch_summary=dispatch_summary, + process_summary=process_summary, + ) + print(json.dumps(status, indent=2, sort_keys=True) if args.json else f"{status['dispatch_status']} {rel(run_dir)}") + create_failures = [item for item in created if item.get("status") == "failed"] + if create_failures: + return 1 + if not args.dry_run and dispatch_summary.get("status") == "failed": + return 1 + return 0 + + +def read_crontab(crontab_file: str = "") -> str: + if crontab_file: + try: + return Path(crontab_file).read_text(encoding="utf-8") + except FileNotFoundError: + return "" + result = subprocess.run(["crontab", "-l"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return result.stdout if result.returncode == 0 else "" + + +def write_crontab(text: str, crontab_file: str = "") -> None: + if crontab_file: + path = Path(crontab_file) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return + result = subprocess.run(["crontab", "-"], input=text, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "crontab install failed") + + +def strip_cron_block(text: str) -> str: + if CRON_BEGIN not in text: + return text.rstrip() + ("\n" if text.strip() else "") + before, rest = text.split(CRON_BEGIN, 1) + if CRON_END not in rest: + return before.rstrip() + ("\n" if before.strip() else "") + _old, after = rest.split(CRON_END, 1) + combined = (before + after).strip() + return combined + ("\n" if combined else "") + + +def cron_block(args: argparse.Namespace) -> str: + interval = int(args.interval_minutes) + if interval <= 0 or interval > 59: + raise ValueError("--interval-minutes must be between 1 and 59 for cron step syntax") + STATE_DIR.mkdir(parents=True, exist_ok=True) + log_path = STATE_DIR / "claude-chores.log" + lock_path = STATE_DIR / "claude-chores.lock" + inner = ( + f"cd {shlex.quote(str(ROOT))} && " + f"./scripts/cento.sh claude-chores run --scope {shlex.quote(args.scope)} " + f"--chore-limit {int(args.chore_limit)} --max-launch {int(args.max_launch)} " + f"--builder-target {int(args.builder_target)} --small-target {int(args.small_target)} " + f"--validator-target {int(args.validator_target)} --coordinator-target {int(args.coordinator_target)} " + f"--runtime {shlex.quote(args.runtime)} --model {shlex.quote(args.model)} --scheduler-trigger cron --json" + ) + command = ( + f"mkdir -p {shlex.quote(str(STATE_DIR))} && " + f"flock -n {shlex.quote(str(lock_path))} bash -lc {shlex.quote(inner)} " + f">> {shlex.quote(str(log_path))} 2>&1" + ) + return "\n".join([CRON_BEGIN, f"*/{interval} * * * * {command}", CRON_END, ""]) + + +def command_install_cron(args: argparse.Namespace) -> int: + try: + block = cron_block(args) + except ValueError as exc: + print(f"claude-chores install-cron: {exc}", file=sys.stderr) + return 2 + current = read_crontab(args.crontab_file) + updated = strip_cron_block(current) + if updated.strip(): + updated = updated.rstrip() + "\n" + updated += block + if not args.dry_run: + write_crontab(updated, args.crontab_file) + payload = { + "status": "planned" if args.dry_run else "installed", + "cron_installed": not args.dry_run, + "cron_block": block, + "crontab_file": args.crontab_file, + "dry_run": bool(args.dry_run), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload["status"]) + return 0 + + +def command_uninstall_cron(args: argparse.Namespace) -> int: + current = read_crontab(args.crontab_file) + updated = strip_cron_block(current) + if not args.dry_run: + write_crontab(updated, args.crontab_file) + payload = { + "status": "planned" if args.dry_run else "uninstalled", + "cron_installed_before": CRON_BEGIN in current and CRON_END in current, + "crontab_file": args.crontab_file, + "dry_run": bool(args.dry_run), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload["status"]) + return 0 + + +def command_status(args: argparse.Namespace) -> int: + latest_status = read_json(RUN_ROOT / "latest" / "status.json") + crontab_text = read_crontab(args.crontab_file) + cron_installed = CRON_BEGIN in crontab_text and CRON_END in crontab_text + active_runs = agent_work_active_runs(include_untracked=True) + issues = agent_work_issues() + payload = { + "status": latest_status.get("dispatch_status", "unknown") if latest_status else "unknown", + "latest_run_dir": latest_status.get("run_dir", "") if latest_status else "", + "latest_status": latest_status, + "cron_installed": cron_installed, + "cron_block": cron_block(args) if cron_installed else "", + "process_summary": process_benefit_summary(active_runs, issues), + "agent_pool_latest": read_json(STATE_DIR / "agent-pool-kick-latest.json"), + "crontab_file": args.crontab_file, + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else f"{payload['status']} cron={payload['cron_installed']}") + return 0 + + +def add_common_run_flags(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--scope", choices=["broad-repo"], default="broad-repo") + parser.add_argument("--run-dir", default="") + parser.add_argument("--json", action="store_true") + + +def add_pool_flags(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--chore-limit", type=int, default=2) + parser.add_argument("--max-launch", type=int, default=2) + parser.add_argument("--builder-target", type=int, default=DEFAULT_ACTIVE_TARGETS["builder"]) + parser.add_argument("--small-target", type=int, default=DEFAULT_ACTIVE_TARGETS["small"]) + parser.add_argument("--validator-target", type=int, default=DEFAULT_ACTIVE_TARGETS["validator"]) + parser.add_argument("--coordinator-target", type=int, default=DEFAULT_ACTIVE_TARGETS["coordinator"]) + parser.add_argument("--runtime", default=DEFAULT_RUNTIME) + parser.add_argument("--model", default=DEFAULT_MODEL) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create and launch bounded Claude Code chores for Cento maintenance.") + sub = parser.add_subparsers(dest="command", required=True) + + plan = sub.add_parser("plan", help="Discover candidate Claude chores and write run artifacts without creating tasks.") + add_common_run_flags(plan) + plan.set_defaults(func=command_plan) + + run = sub.add_parser("run", help="Create bounded Claude chores and kick the Claude-only worker pool.") + add_common_run_flags(run) + add_pool_flags(run) + run.add_argument("--dry-run", action="store_true") + run.add_argument("--scheduler-trigger", default="") + run.set_defaults(func=command_run) + + status = sub.add_parser("status", help="Show latest chore run, cron, active process, and pool status.") + add_pool_flags(status) + status.add_argument("--scope", choices=["broad-repo"], default="broad-repo") + status.add_argument("--interval-minutes", type=int, default=30) + status.add_argument("--crontab-file", default=os.environ.get("CENTO_CLAUDE_CHORES_CRONTAB_PATH", "")) + status.add_argument("--json", action="store_true") + status.set_defaults(func=command_status) + + install = sub.add_parser("install-cron", help="Install the managed Claude chores cron block.") + add_pool_flags(install) + install.add_argument("--scope", choices=["broad-repo"], default="broad-repo") + install.add_argument("--interval-minutes", type=int, default=30) + install.add_argument("--crontab-file", default=os.environ.get("CENTO_CLAUDE_CHORES_CRONTAB_PATH", "")) + install.add_argument("--dry-run", action="store_true") + install.add_argument("--json", action="store_true") + install.set_defaults(func=command_install_cron) + + uninstall = sub.add_parser("uninstall-cron", help="Remove the managed Claude chores cron block.") + uninstall.add_argument("--crontab-file", default=os.environ.get("CENTO_CLAUDE_CHORES_CRONTAB_PATH", "")) + uninstall.add_argument("--dry-run", action="store_true") + uninstall.add_argument("--json", action="store_true") + uninstall.set_defaults(func=command_uninstall_cron) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except RuntimeError as exc: + print(f"claude-chores: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codebase_intelligence.py b/scripts/codebase_intelligence.py new file mode 100644 index 0000000..45f0500 --- /dev/null +++ b/scripts/codebase_intelligence.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +"""Codebase Intelligence: local repository scanner for capability graph, inspector, and health data.""" +from __future__ import annotations + +import ast +import fnmatch +import json +import os +from pathlib import Path +from typing import Any + +ROOT_DIR = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# Capability definitions: each capability groups script patterns into a node +# --------------------------------------------------------------------------- +CAPABILITIES: list[dict[str, Any]] = [ + { + "id": "agent-work", + "label": "Agent Work", + "description": "Taskstream issue tracking, builder/validator workflow, local DB backend", + "color": "#4f8ef7", + "patterns": ["agent_work*.py"], + "kind": "core", + "route": "/", + }, + { + "id": "factory", + "label": "Factory Autopilot", + "description": "Automated factory pipeline, policy matrix, and run dispatch", + "color": "#f7a04f", + "patterns": ["factory_autopilot*.py", "factory_dispatch*.py", "factory_console*.py"], + "kind": "core", + "route": "/factory", + }, + { + "id": "cluster", + "label": "Cluster & Industrial", + "description": "Cluster job dispatch, industrial actions, focus, and panel", + "color": "#4ff7a0", + "patterns": ["cluster_*.py", "industrial_*.py"], + "kind": "infrastructure", + "route": "/cluster", + }, + { + "id": "cento-cli", + "label": "Cento CLI", + "description": "Unified CLI facade, interactive shell, run-mode, build, workset, runtime", + "color": "#a04ff7", + "patterns": ["cento_*.py"], + "kind": "tooling", + "route": None, + }, + { + "id": "storage", + "label": "Storage", + "description": "Storage policies and data persistence layer", + "color": "#f74f4f", + "patterns": ["storage*.py"], + "kind": "infrastructure", + "route": None, + }, + { + "id": "mcp", + "label": "MCP Server", + "description": "Model Context Protocol server and tooling integration", + "color": "#f7e04f", + "patterns": ["*mcp*.py"], + "kind": "tooling", + "route": None, + }, + { + "id": "docs", + "label": "Docs", + "description": "Documentation rendering, browsing, and delivery hub", + "color": "#4fd4f7", + "patterns": ["docs_*.py", "deliverables_hub.py"], + "kind": "ui", + "route": "/docs", + }, + { + "id": "research", + "label": "Research Center", + "description": "Research map and context gathering", + "color": "#f74fa0", + "patterns": ["research_*.py", "gather_*.py"], + "kind": "ui", + "route": "/research-center", + }, + { + "id": "consulting", + "label": "Consulting & Funnel", + "description": "Funnel checks, CRM module, and scan one-pager", + "color": "#a0f74f", + "patterns": ["funnel_*.py", "crm_*.py", "scan_*.py"], + "kind": "business", + "route": "/consulting", + }, + { + "id": "validation", + "label": "Validation", + "description": "Story manifest validation, contract checks, and validator tiers", + "color": "#4f4ff7", + "patterns": [ + "*validation*.py", + "*validate*.py", + "*contract_check*.py", + "story_*.py", + "validator_*.py", + "manifest_*.py", + "no_model_*.py", + ], + "kind": "quality", + "route": None, + }, + { + "id": "agent-manager", + "label": "Agent Manager", + "description": "Agent pool coordination, manager, and coordinator", + "color": "#e04ff7", + "patterns": ["agent_manager*.py", "agent_coordinator*.py", "agent_pool*.py"], + "kind": "core", + "route": None, + }, + { + "id": "platform", + "label": "Platform & Tools", + "description": "Platform reporting, tool index, network server, dashboard", + "color": "#f7c04f", + "patterns": [ + "platform_*.py", + "tool_index.py", + "network_*.py", + "dashboard_*.py", + "jobs_server.py", + "idea_board_server.py", + "bluetooth_*.py", + ], + "kind": "tooling", + "route": "/dev-pipeline-studio", + }, +] + +# Built-in cross-capability dependency hints (supplement import scanning) +CAPABILITY_DEPS: list[tuple[str, str]] = [ + ("factory", "agent-work"), + ("cluster", "agent-work"), + ("docs", "agent-work"), + ("validation", "agent-work"), + ("validation", "factory"), + ("agent-manager", "agent-work"), + ("cento-cli", "factory"), + ("cento-cli", "storage"), +] + +# Known HTTP route groups +ROUTE_GROUPS: list[dict[str, Any]] = [ + {"prefix": "/health", "methods": ["GET"], "module": "agent-work", "description": "App health check"}, + {"prefix": "/api/issues", "methods": ["GET", "POST", "PATCH"], "module": "agent-work", "description": "Issue CRUD"}, + {"prefix": "/api/runs", "methods": ["GET"], "module": "agent-work", "description": "Agent-work run list"}, + {"prefix": "/api/review", "methods": ["GET", "POST"], "module": "agent-work", "description": "Validator review queue and decisions"}, + {"prefix": "/api/factory", "methods": ["GET"], "module": "factory", "description": "Factory pipeline runs"}, + {"prefix": "/api/sync", "methods": ["GET"], "module": "agent-work", "description": "Sync from agent-work backend"}, + {"prefix": "/api/projects", "methods": ["GET"], "module": "agent-work", "description": "Project reference list"}, + {"prefix": "/api/trackers", "methods": ["GET"], "module": "agent-work", "description": "Tracker reference list"}, + {"prefix": "/api/statuses", "methods": ["GET"], "module": "agent-work", "description": "Status reference list"}, + {"prefix": "/api/artifacts", "methods": ["GET"], "module": "agent-work", "description": "Serve local artifact files"}, + {"prefix": "/api/queries", "methods": ["GET", "POST"], "module": "agent-work", "description": "Saved queries"}, + {"prefix": "/api/codebase-intelligence", "methods": ["GET"], "module": "codebase-intelligence", "description": "Codebase Intelligence inventory and graph"}, + {"prefix": "/api/codebase-intelligence/graph", "methods": ["GET"], "module": "codebase-intelligence", "description": "Capability graph nodes and edges"}, + {"prefix": "/api/codebase-intelligence/inspect", "methods": ["GET"], "module": "codebase-intelligence", "description": "File inspector details"}, +] + +# Known data stores +DATASTORES: list[dict[str, str]] = [ + {"id": "sqlite-agent-work", "label": "Agent Work SQLite DB", "kind": "sqlite", "path": "~/.local/state/cento/agent-work-app.sqlite3", "used_by": "agent-work"}, + {"id": "tools-registry", "label": "Tools Registry", "kind": "json-file", "path": "data/tools.json", "used_by": "platform"}, + {"id": "cento-cli-registry", "label": "CLI Commands Registry", "kind": "json-file", "path": "data/cento-cli.json", "used_by": "cento-cli"}, + {"id": "agent-runtimes", "label": "Agent Runtimes", "kind": "json-file", "path": "data/agent-runtimes.json", "used_by": "cluster"}, + {"id": "storage-policies", "label": "Storage Policies", "kind": "json-file", "path": "data/storage-policies.json", "used_by": "storage"}, + {"id": "industrial-actions", "label": "Industrial Actions", "kind": "json-file", "path": "data/industrial-actions.json", "used_by": "cluster"}, + {"id": "runtimes-yaml", "label": "Runtime Config", "kind": "yaml-file", "path": ".cento/runtimes.yaml", "used_by": "cento-cli"}, + {"id": "api-workers-yaml", "label": "API Workers", "kind": "yaml-file", "path": ".cento/api_workers.yaml", "used_by": "cento-cli"}, +] + + +# --------------------------------------------------------------------------- +# File scanning +# --------------------------------------------------------------------------- + +def _scripts_dir() -> Path: + return ROOT_DIR / "scripts" + + +def _file_matches(name: str, patterns: list[str]) -> bool: + return any(fnmatch.fnmatch(name, p) for p in patterns) + + +def _capability_for_file(name: str) -> str | None: + for cap in CAPABILITIES: + if _file_matches(name, cap["patterns"]): + return cap["id"] + return None + + +def _count_lines(path: Path) -> int: + try: + return sum(1 for _ in path.open("r", errors="replace")) + except OSError: + return 0 + + +def _parse_ast(path: Path) -> ast.Module | None: + try: + src = path.read_text(errors="replace") + return ast.parse(src, filename=str(path)) + except SyntaxError: + return None + + +def _extract_imports(tree: ast.Module) -> list[str]: + """Return local module names imported (stdlib/third-party filtered out).""" + stdlib_prefixes = { + "os", "sys", "re", "json", "ast", "abc", "io", "math", "time", + "datetime", "collections", "itertools", "functools", "typing", + "pathlib", "subprocess", "threading", "socket", "signal", + "sqlite3", "hashlib", "uuid", "base64", "shutil", "shlex", + "argparse", "textwrap", "fnmatch", "mimetypes", "tempfile", + "http", "urllib", "webbrowser", "logging", "copy", "enum", + "dataclasses", "contextlib", "traceback", "inspect", "platform", + "struct", "array", "queue", "weakref", "gc", "string", + "__future__", + } + local_imports: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + top = alias.name.split(".")[0] + if top not in stdlib_prefixes: + local_imports.append(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module: + top = node.module.split(".")[0] + if top not in stdlib_prefixes: + local_imports.append(node.module) + return sorted(set(local_imports)) + + +def _extract_functions(tree: ast.Module) -> list[str]: + return [ + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and not node.name.startswith("_") + ] + + +def _extract_classes(tree: ast.Module) -> list[str]: + return [node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)] + + +def _module_docstring(tree: ast.Module) -> str: + val = ast.get_docstring(tree) or "" + return val.splitlines()[0] if val else "" + + +def scan_scripts() -> list[dict[str, Any]]: + """Return metadata for every .py file under scripts/.""" + scripts = _scripts_dir() + result: list[dict[str, Any]] = [] + for path in sorted(scripts.glob("*.py")): + name = path.name + rel = str(path.relative_to(ROOT_DIR)) + tree = _parse_ast(path) + imports = _extract_imports(tree) if tree else [] + functions = _extract_functions(tree) if tree else [] + classes = _extract_classes(tree) if tree else [] + docstring = _module_docstring(tree) if tree else "" + lines = _count_lines(path) + cap = _capability_for_file(name) + result.append({ + "name": name, + "path": rel, + "capability": cap, + "lines": lines, + "functions": len(functions), + "classes": len(classes), + "imports": imports, + "docstring": docstring, + }) + return result + + +# --------------------------------------------------------------------------- +# Capability graph +# --------------------------------------------------------------------------- + +def build_graph() -> dict[str, Any]: + """Build nodes and edges from live file scan + known hints.""" + files = scan_scripts() + + # Aggregate metrics per capability + cap_files: dict[str, list[dict[str, Any]]] = {c["id"]: [] for c in CAPABILITIES} + unassigned: list[dict[str, Any]] = [] + for f in files: + if f["capability"] and f["capability"] in cap_files: + cap_files[f["capability"]].append(f) + else: + unassigned.append(f) + + # Build nodes + nodes: list[dict[str, Any]] = [] + for cap in CAPABILITIES: + cid = cap["id"] + flist = cap_files[cid] + total_lines = sum(f["lines"] for f in flist) + total_fn = sum(f["functions"] for f in flist) + nodes.append({ + "id": cid, + "label": cap["label"], + "description": cap["description"], + "color": cap["color"], + "kind": cap["kind"], + "route": cap.get("route"), + "file_count": len(flist), + "total_lines": total_lines, + "total_functions": total_fn, + "files": [f["path"] for f in flist], + }) + + # Build edges from import scanning + # Map script module stem -> capability id + stem_to_cap: dict[str, str] = {} + for f in files: + if f["capability"]: + stem = Path(f["name"]).stem + stem_to_cap[stem] = f["capability"] + + edge_set: set[tuple[str, str]] = set() + for f in files: + if not f["capability"]: + continue + src_cap = f["capability"] + for imp in f["imports"]: + imp_stem = imp.split(".")[0] + dst_cap = stem_to_cap.get(imp_stem) + if dst_cap and dst_cap != src_cap: + edge_set.add((src_cap, dst_cap)) + + # Add built-in hints not already covered + for src, dst in CAPABILITY_DEPS: + edge_set.add((src, dst)) + + edges: list[dict[str, str]] = [{"source": s, "target": t} for s, t in sorted(edge_set)] + + return { + "nodes": nodes, + "edges": edges, + "unassigned_files": [f["path"] for f in unassigned], + "total_scripts": len(files), + } + + +# --------------------------------------------------------------------------- +# File inspector +# --------------------------------------------------------------------------- + +def inspect_file(rel_path: str) -> dict[str, Any]: + """Return inspector payload for a repository file.""" + # Sanitize path — must stay inside ROOT_DIR + candidate = (ROOT_DIR / rel_path).resolve() + if ROOT_DIR.resolve() not in candidate.parents and candidate != ROOT_DIR.resolve(): + return {"error": "path is outside repository", "path": rel_path} + if not candidate.exists(): + return {"error": "file not found", "path": rel_path} + if not candidate.is_file(): + return {"error": "path is not a file", "path": rel_path} + + lines = _count_lines(candidate) + result: dict[str, Any] = { + "path": rel_path, + "lines": lines, + "size_bytes": candidate.stat().st_size, + "extension": candidate.suffix, + } + + if candidate.suffix == ".py": + tree = _parse_ast(candidate) + if tree: + imports = _extract_imports(tree) + functions = _extract_functions(tree) + classes = _extract_classes(tree) + docstring = _module_docstring(tree) + cap = _capability_for_file(candidate.name) + result.update({ + "docstring": docstring, + "capability": cap, + "imports": imports, + "public_functions": functions, + "classes": classes, + "function_count": len(functions), + "class_count": len(classes), + "import_count": len(imports), + "health": _health_score(lines, functions, docstring), + }) + else: + result["parse_error"] = True + elif candidate.suffix == ".json": + try: + data = json.loads(candidate.read_text(errors="replace")) + result["json_keys"] = list(data.keys()) if isinstance(data, dict) else None + result["json_items"] = len(data) if isinstance(data, (dict, list)) else None + except (json.JSONDecodeError, OSError): + result["json_parse_error"] = True + + return result + + +def _health_score(lines: int, functions: list[str], docstring: str) -> dict[str, Any]: + """Simple heuristic health score for a Python file.""" + score = 100 + issues: list[str] = [] + if lines > 2000: + score -= 20 + issues.append("large file (>2000 lines)") + elif lines > 1000: + score -= 10 + issues.append("large file (>1000 lines)") + if not docstring: + score -= 10 + issues.append("no module docstring") + if not functions: + score -= 5 + issues.append("no public functions") + return {"score": max(0, score), "issues": issues} + + +# --------------------------------------------------------------------------- +# Health summary +# --------------------------------------------------------------------------- + +def health_summary() -> dict[str, Any]: + """Aggregate health metrics across all scripts.""" + files = scan_scripts() + total = len(files) + with_docstring = sum(1 for f in files if f.get("docstring")) + large_files = [f["path"] for f in files if f["lines"] > 1000] + total_lines = sum(f["lines"] for f in files) + total_functions = sum(f["functions"] for f in files) + uncategorized = [f["path"] for f in files if not f["capability"]] + + tests_dir = ROOT_DIR / "tests" + test_files = list(tests_dir.glob("test_*.py")) if tests_dir.exists() else [] + + return { + "script_count": total, + "total_lines": total_lines, + "total_functions": total_functions, + "test_file_count": len(test_files), + "with_docstring_pct": round(with_docstring / total * 100) if total else 0, + "large_files": large_files, + "uncategorized_files": uncategorized, + "data_file_count": len([p for p in (ROOT_DIR / "data").glob("*.json") if p.is_file()]) if (ROOT_DIR / "data").exists() else 0, + "doc_count": len(list((ROOT_DIR / "docs").glob("*.md"))) if (ROOT_DIR / "docs").exists() else 0, + } + + +# --------------------------------------------------------------------------- +# Full inventory (page payload) +# --------------------------------------------------------------------------- + +def inventory() -> dict[str, Any]: + """Full Codebase Intelligence page payload.""" + graph = build_graph() + health = health_summary() + return { + "graph": graph, + "health": health, + "routes": ROUTE_GROUPS, + "datastores": DATASTORES, + "capabilities": [ + { + "id": c["id"], + "label": c["label"], + "description": c["description"], + "kind": c["kind"], + "route": c.get("route"), + "color": c["color"], + } + for c in CAPABILITIES + ], + } diff --git a/scripts/completion/_cento b/scripts/completion/_cento index c5e8a81..9e513f9 100755 --- a/scripts/completion/_cento +++ b/scripts/completion/_cento @@ -100,6 +100,16 @@ _cento() { esac fi ;; + discord|rd) + if (( CURRENT == 3 )); then + _values 'discord command' \ + 'status[Show Discord launcher and process state]' \ + 'update[Install latest official Linux tarball into the user profile]' \ + 'rerun[Stop and relaunch Discord]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "update" ]]; then + _values 'discord update option' '--rerun[Restart Discord after updating]' + fi + ;; bridge) if (( CURRENT == 3 )); then _values 'bridge command' \ @@ -178,6 +188,74 @@ _cento() { _values 'incident key' iphone-ce fi ;; + foundry) + if (( CURRENT == 3 )); then + _values 'foundry command' \ + 'create[Create a Foundry run spec and fixture tool artifacts]' \ + 'plan[Generate Factory handoff and Workset manifest]' \ + 'execute[Execute the Workset through parallel train e2e]' \ + 'promote[Re-run Factory promotion for the train run]' \ + 'materialize[Plan or apply repo-ready files from a Foundry run]' \ + 'status[Show Foundry run status]' \ + 'validate[Validate Foundry receipts and gates]' \ + 'e2e[Create, plan, execute, promote, and validate a fixture run]' + else + case "${words[3]:-}" in + create) + _arguments \ + '--domain[Business domain]:domain:' \ + '--fixture[Fixture tool]:fixture:(client-intake-hub)' \ + '--run-id[Run id]:run id:' \ + '--out[Output run directory]:directory:_files -/' \ + '--max-parallel[Maximum parallel workers]:count:' \ + '--budget-usd[Target budget in USD]:amount:' \ + '--max-budget-usd[Hard budget cap in USD]:amount:' \ + '--json[Print JSON]' + ;; + execute) + _arguments \ + '--runtime[Runtime]:runtime:(fixture api-openai)' \ + '--train-run-id[Train run id]:run id:' \ + '--budget-usd[Target budget in USD]:amount:' \ + '--max-budget-usd[Hard budget cap in USD]:amount:' \ + '--max-parallel[Maximum parallel workers]:count:' \ + '--validation[Validation tier]:tier:' \ + '--json[Print JSON]' + ;; + promote) + _arguments '--dry-run[Plan promotion without applying]' '--apply[Apply in Factory integration worktree]' '--json[Print JSON]' + ;; + materialize) + _arguments \ + '--target-root[Repo-relative materialization root]:path:_files -/' \ + '--dry-run[Plan real files without writing]' \ + '--apply[Write repo-ready files]' \ + '--json[Print JSON]' + ;; + e2e) + _arguments \ + '--fixture[Fixture tool]:fixture:(client-intake-hub)' \ + '--idea[Tool idea]:idea:' \ + '--domain[Business domain]:domain:' \ + '--run-id[Run id]:run id:' \ + '--out[Output run directory]:directory:_files -/' \ + '--max-parallel[Maximum parallel workers]:count:' \ + '--dry-run[Use fixture runtime]' \ + '--live[Use live api-openai runtime]' \ + '--real-files[Plan real-file materialization after validation]' \ + '--target-root[Repo-relative materialization root]:path:_files -/' \ + '--materialize-apply[Apply real files during e2e]' \ + '--budget-usd[Target budget in USD]:amount:' \ + '--max-budget-usd[Hard budget cap in USD]:amount:' \ + '--validation[Validation tier]:tier:' \ + '--json[Print JSON]' + ;; + plan|status|validate) + _arguments '--json[Print JSON]' + ;; + esac + fi + ;; agent-work) if (( CURRENT == 3 )); then _values 'agent-work command' \ @@ -223,6 +301,110 @@ _cento() { 'create-investigation-ticket[Create a follow-up ticket]' fi ;; + build) + if (( CURRENT == 3 )); then + _values 'build command' \ + 'init[Create a build manifest and Builder prompt]' \ + 'check[Validate manifest shape and path policy]' \ + 'prompt[Print or rewrite the Builder prompt]' \ + 'worker[Run one local build worker]' \ + 'apply[Apply an accepted patch bundle]' \ + 'artifact[Check worker artifacts]' \ + 'bundle[Synthesize patch bundles]' \ + 'integrate[Dry-run integrate a patch bundle]' \ + 'receipt[Print the latest build receipt]' + else + _arguments \ + '--task[Operator task]:task:' \ + '--mode[Execution mode]:mode:(fast standard thorough)' \ + '--write[Owned writable path]:file:_files' \ + '--read[Read-only path]:file:_files' \ + '--route[Target route]:route:' \ + '--patch[Patch diff path]:file:_files' \ + '--bundle[Patch bundle path]:file:_files' \ + '--from-receipt[Accepted integration receipt]:file:_files' \ + '--worker[Worker id]:worker:(builder_1)' \ + '--runtime[Worker runtime]:runtime:(fixture command fixture-unowned fixture-protected fixture-binary fixture-delete fixture-lockfile local-codex codex)' \ + '--runtime-profile[Named runtime profile]:profile:(codex-fast fixture-valid python-fixture)' \ + '--fixture-case[Fixture case]:case:(valid unowned protected delete lockfile binary)' \ + '--command[Command runtime template]:command:' \ + '--local-builder[Run one local builder runtime]:runtime:(fixture command)' \ + '--builder-command[Run-fast command runtime template]:command:' \ + '--worker-timeout[Run-fast worker timeout seconds]:seconds:' \ + '--timeout[Worker timeout seconds]:seconds:' \ + '--worktree[Run in an isolated worktree]' \ + '--allow-dirty-owned[Allow dirty owned paths]' \ + '--allow-unsafe-command[Allow raw shell command runtime]' \ + '--allow-base-mismatch[Allow manifest base mismatch]' \ + '--dev-raw-patch[Allow raw patch integration for dev]' \ + '--dry-run[Run integration as a dry-run]' \ + '--json[Print JSON]' + fi + ;; + runtime) + if (( CURRENT == 3 )); then + _values 'runtime command' \ + 'list[List runtime profiles]' \ + 'check[Validate one runtime profile]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "check" ]]; then + _values 'runtime profile' codex-fast fixture-valid python-fixture + else + _arguments \ + '--json[Print JSON]' \ + '--require-executable[Fail when command executable is missing]' + fi + ;; + workset) + if (( CURRENT == 3 )); then + _values 'workset command' \ + 'check[Validate workset shape and exclusive paths]' \ + 'run[Run a local exclusive-path workset]' \ + 'execute[Run parallel fixture, local-command, or API workers]' \ + 'materialize-artifact[Convert API artifact to patch bundle]' + else + _arguments \ + '--max-parallel[Maximum parallel workers]:count:' \ + '--max-workers[Maximum parallel workers]:count:' \ + '--runtime[Worker runtime]:runtime:(api-openai fixture local-command)' \ + '--runtime-profile[Named runtime profile]:profile:(codex-fast fixture-valid python-fixture)' \ + '--api-profile[API worker profile]:profile:(api-planner api-section-worker api-reviewer)' \ + '--budget-usd[Target API budget]:usd:' \ + '--max-budget-usd[Hard API budget cap]:usd:' \ + '--integrate[Integration strategy]:strategy:(sequential)' \ + '--apply[Apply mode]:mode:(sequential none)' \ + '--validation[Validation tier]:tier:(smoke focused product)' \ + '--worker-timeout[Worker timeout seconds]:seconds:' \ + '--fixture-case[Fixture case]:case:(valid unowned protected delete lockfile binary)' \ + '--allow-dirty-owned[Allow dirty owned paths]' \ + '--allow-unsafe-command[Allow raw shell command runtime]' \ + '--json[Print JSON]' + fi + ;; + demo-evidence) + if (( CURRENT == 3 )); then + _values 'demo-evidence command' \ + 'record[Record a 10-30 second demo clip and evidence receipt]' \ + 'verify[Verify a demo evidence receipt and video]' \ + 'status[Print a demo evidence receipt status]' + else + _arguments \ + '--title[Evidence title]:title:' \ + '--duration[Clip length in seconds, 10-30]:seconds:' \ + '--fps[Capture frame rate]:fps:' \ + '--geometry[Capture region WIDTHxHEIGHT+X,Y]:geometry:' \ + '--out[Output run directory]:directory:_files -/' \ + '--factory-run[Factory run directory]:directory:_files -/' \ + '--task[Factory task id]:task:' \ + '--worker[Worker id]:worker:' \ + '--notes[Evidence note]:note:' \ + '--tag[Evidence tag]:tag:' \ + '--video-name[Video filename]:file:' \ + '--recorder[Recorder backend]:recorder:(auto x11grab wf-recorder avfoundation synthetic)' \ + '--avfoundation-input[macOS ffmpeg avfoundation input]:input:' \ + '--dry-run[Plan recording without capture]' \ + '--json[Print JSON]' + fi + ;; factory) if (( CURRENT == 3 )); then _values 'factory command' \ @@ -238,7 +420,9 @@ _cento() { 'validate[Run T0/T1/T2 validation ladder]' \ 'integrate[Plan, prepare branch, or apply patches via Safe Integrator]' \ 'validate-integrated[Validate integration state and merge readiness]' \ + 'validate-fanout[Run parallel cached candidate validation]' \ 'release-candidate[Render integration release-candidate.md]' \ + 'merge[Auto-merge validated integration branch into main]' \ 'sync-taskstream[Preview Taskstream transitions from integration results]' \ 'release[Write delivery status]' \ 'render-hub[Render start-here and implementation map]' \ @@ -259,10 +443,199 @@ _cento() { _arguments \ '--task[Factory task id]:task:' \ '--runtime[Runtime adapter]:runtime:(noop local-shell-fixture codex-dry-run)' \ + '--max-parallel[Maximum validation fanout]:count:' \ + '--auto-merge[Evaluate auto-merge readiness]' \ + '--auto-merge-main[Allow local main auto-merge]' \ + '--push[Push after post-merge validation]' \ + '--target-branch[Target branch]:branch:' \ + '--remote[Git remote]:remote:' \ '--dry-run[Do not execute real workers]' \ '--json[Print JSON]' fi ;; + parallel-delivery) + if (( CURRENT == 3 )); then + _values 'parallel-delivery command' \ + 'plan[Write implementation manifest and demo workset]' \ + 'execute[Run Hard ProReq passes and demo validation]' \ + 'demo[Create or execute the 10-lane fixture demo]' \ + 'validate[Validate a parallel delivery run]' \ + 'status[Summarize a parallel delivery run]' \ + 'train[Plan and run a dry-run parallel integration train]' \ + 'patch-swarm[Generate, rank, and integrate many provider-diverse patch candidates]' \ + 'self-improve[Run and manage the gated nightly self-improvement loop]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "train" ]]; then + _values 'parallel-delivery train command' \ + 'plan[Create a train manifest and integration queue]' \ + 'run[Simulate train workers or execute the copied Workset]' \ + 'promote[Promote train receipts into Factory Safe Integrator]' \ + 'e2e[Plan, execute, validate, and promote a train]' \ + 'integrate[Dry-run sequential train integration]' \ + 'status[Show train status]' \ + 'validate[Validate train artifacts]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "patch-swarm" ]]; then + _values 'parallel-delivery patch-swarm command' \ + 'plan[Create a Patch Swarm manifest]' \ + 'execute[Generate fixture candidate receipts]' \ + 'integrate[Run the dedicated integrator]' \ + 'validate[Validate Patch Swarm artifacts]' \ + 'status[Show Patch Swarm status]' \ + 'e2e[Plan, generate, integrate, and validate]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "self-improve" ]]; then + _values 'parallel-delivery self-improve command' \ + 'run[Run the four-pass planning loop]' \ + 'e2e[Run Patch Swarm, Factory, Safe Integrator, and dry-run merge gates]' \ + 'validate[Validate the selected self-improvement run]' \ + 'status[Show self-improvement status]' \ + 'install-cron[Install the nightly cron block]' \ + 'uninstall-cron[Remove the nightly cron block]' + else + _arguments \ + '--workset[Workset manifest]:file:_files' \ + '--max-parallel[Maximum parallel shards]:count:' \ + '--run-id[Train or delivery run id]:run-id:' \ + '--run-dir[Run directory]:directory:_files -/' \ + '--candidate-target[Patch candidate target]:count:' \ + '--max-parallel-agents[Maximum parallel agents]:count:' \ + '--budget-cap-usd[Patch Swarm live budget cap]:usd:' \ + '--max-budget-usd[Hard live budget cap]:usd:' \ + '--api-sandbox-candidates[Metered api-openai sandbox candidate limit]:count:' \ + '--budget-cap[Patch Swarm live budget cap]:usd:' \ + '--providers[Patch providers]:providers:' \ + '--fixture-only[Use deterministic self-improvement e2e fixture mode]' \ + '--fixture[Use fixture candidates]' \ + '--auto-merge-gate[Run Factory auto-merge gate as dry-run]' \ + '--simulate[Simulate workers]' \ + '--workset-execute[Run copied Workset through cento workset execute]' \ + '--runtime[Train worker runtime]:runtime:(fixture local-command api-openai)' \ + '--runtime-profile[Named runtime profile]:profile:(codex-fast fixture-valid python-fixture)' \ + '--api-profile[API worker profile]:profile:(api-planner api-section-worker api-reviewer)' \ + '--api-config[API worker config]:file:_files' \ + '--budget-usd[Target API budget]:usd:' \ + '--max-budget-usd[Hard API budget cap]:usd:' \ + '--validation[Validation tier]:tier:(smoke focused product)' \ + '--worker-timeout[Worker timeout seconds]:seconds:' \ + '--retry-attempts[API retry attempts]:count:' \ + '--fixture-case[Fixture case]:case:(valid unowned protected delete lockfile binary)' \ + '--allow-dirty-owned[Allow dirty owned paths]' \ + '--allow-creates[Allow explicit owned creates]' \ + '--apply[Apply accepted patches in Factory integration worktree]' \ + '--factory-run[Factory run directory]:directory:_files -/' \ + '--validate-each[Validate after each applied patch]' \ + '--branch[Factory integration branch]:branch:' \ + '--worktree[Factory integration worktree]:directory:_files -/' \ + '--limit[Maximum patches to apply]:count:' \ + '--dry-run[Plan integration without applying]' \ + '--live-pro[Enable live Pro planning where supported]' \ + '--skip-demo[Skip demo execution]' \ + '--json[Print JSON]' + fi + ;; + walk-autopilot) + if (( CURRENT == 3 )); then + _values 'walk-autopilot command' \ + 'run[Run append-only walk loops in the foreground]' \ + 'start-tmux[Start walk loops in a detached tmux session]' \ + 'status[Show latest or named walk autopilot status]' \ + 'review-unblock[Scan Agent Work review and blocked states]' \ + 'patch-swarm[Run Patch Swarm fixture e2e through Walk Autopilot]' \ + 'factory-scale[Run the six-hour Factory scale final test]' \ + 'routing[Run and manage the lightweight routing nativeness loop]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "review-unblock" ]]; then + _values 'walk-autopilot review-unblock command' \ + 'run[Run one Review/Unblock decision pass]' \ + 'status[Show latest Review/Unblock run status]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "patch-swarm" ]]; then + _values 'walk-autopilot patch-swarm command' \ + 'run[Run one fixture Patch Swarm e2e and write summary artifacts]' \ + 'status[Show latest Patch Swarm autopilot summary]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "factory-scale" ]]; then + _values 'walk-autopilot factory-scale command' \ + 'start[Initialize and schedule the Factory scale final test]' \ + 'start-day[Initialize and schedule the day-scale Factory autopilot run]' \ + 'preflight[Check for active Factory scale lanes before starting or advancing]' \ + 'advance[Index completed receipts and write Safe Integrator promotion artifacts]' \ + 'promote[Promote advance candidates into a Factory validation run]' \ + 'tick[Run one Factory scale tick]' \ + 'status[Show log-derived Factory scale status]' \ + 'install-cron[Install the marked Factory scale cron block]' \ + 'uninstall-cron[Remove the marked Factory scale cron block]' + elif (( CURRENT == 4 )) && [[ "${words[3]:-}" == "routing" ]]; then + _values 'walk-autopilot routing command' \ + 'run[Collect counts-only routing stats and write a decision report]' \ + 'status[Show routing nativeness run and cron status]' \ + 'install-cron[Install the marked four-hour cron block]' \ + 'uninstall-cron[Remove the marked cron block]' + else + _arguments \ + '--run-id[Run id]:run-id:' \ + '--loops[Loop count]:count:' \ + '--cadence-seconds[Seconds between loops]:seconds:' \ + '--soft-cap-usd[Soft spend cap]:usd:' \ + '--hard-cap-usd[Hard spend cap]:usd:' \ + '--max-worker-launch[Max worker launch count]:count:' \ + '--factory-run-id[Factory run id]:run-id:' \ + '--review-unblock-mode[Review/Unblock loop mode]:mode:(report aggressive)' \ + '--no-review-unblock[Skip Review/Unblock stage]' \ + '--mode[Review/Unblock standalone mode]:mode:(report aggressive)' \ + '--every-hours[Routing cron cadence in hours]:hours:' \ + '--duration-hours[Factory scale duration in hours]:hours:' \ + '--proreq-executions[Factory scale ProReq-light execution count]:count:' \ + '--min-proreq-calls[Minimum ProReq-light command-call records]:count:' \ + '--target-proreq-calls[Target day-scale ProReq-light command-call records]:count:' \ + '--max-proreq-calls[Maximum allowed day-scale ProReq-light command-call records]:count:' \ + '--tick-minutes[Factory scale cron cadence in minutes]:minutes:' \ + '--batch-size[Factory scale executions per tick]:count:' \ + '--promotion-limit[Selected candidate promotion plan limit]:count:' \ + '--promotion-plan[Factory scale Safe Integrator promotion plan]:path:_files' \ + '--factory-run[Factory run directory for promotion]:path:_files' \ + '--limit[Promotion candidate limit]:count:' \ + '--exclusive-paths[Skip candidates with overlapping touched paths]' \ + '--allow-path-overlap[Let Factory reject overlapping owned paths]' \ + '--allow-incomplete[Allow advance against an incomplete Factory scale run]' \ + '--allow-live-api[Enable live OpenAI/API lane if budget and rate gates pass]' \ + '--dashboard-total-spend-usd[OpenAI dashboard total spend snapshot]:usd:' \ + '--max-live-calls-per-hour[Live OpenAI/API hourly call limit]:count:' \ + '--min-live-call-spacing-seconds[Live OpenAI/API minimum call spacing]:seconds:' \ + '--patch-swarm[Enable Patch Swarm fixture milestones]' \ + '--no-patch-swarm[Disable Patch Swarm fixture milestones]' \ + '--execute-proreq[Run ProReq-light commands instead of ledger-only mode]' \ + '--no-install-cron[Initialize factory-scale artifacts without installing cron]' \ + '--crontab-file[Alternate crontab file]:file:_files' \ + '--no-agent-work[Write routing reports without Agent Work mutation]' \ + '--dry-run[Plan without writing crontab]' \ + '--json[Print JSON]' + fi + ;; + object-storage) + if (( CURRENT == 3 )); then + _values 'object-storage command' \ + 'status[Check local OCI Object Storage readiness]' \ + 'ensure-bucket[Create or verify a private Standard image bucket]' \ + 'put-dummy[Write and upload a dummy text object]' \ + 'e2e[Run Object Storage MVP e2e validation]' \ + 'plan-images[Write a mirror-only image migration manifest]' \ + 'upload-images[Upload image manifest objects]' \ + 'verify-images[Download and verify uploaded image objects]' + else + _arguments \ + '--bucket[OCI Object Storage bucket name]:bucket:' \ + '--name[OCI Object Storage bucket name]:bucket:' \ + '--namespace[OCI Object Storage namespace]:namespace:' \ + '--region[OCI Object Storage region]:region:' \ + '--compartment-id[OCI compartment OCID]:ocid:' \ + '--root[Image migration root]:directory:_files -/' \ + '--manifest[Image migration manifest or receipt]:file:_files' \ + '--sample[Verification sample size]:count:' \ + '--prefix[Object name prefix]:prefix:' \ + '--object-name[Full object name]:object:' \ + '--out[Run output directory]:directory:_files -/' \ + '--dry-run[Do not call OCI]' \ + '--live[Use live OCI upload for e2e]' \ + '--probe[Probe OCI namespace]' \ + '--json[Print JSON]' + fi + ;; storage) if (( CURRENT == 3 )); then _values 'storage command' \ diff --git a/scripts/compute_policy.py b/scripts/compute_policy.py new file mode 100755 index 0000000..140b7b7 --- /dev/null +++ b/scripts/compute_policy.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_POLICY_PATH = ROOT / ".cento" / "compute-policy.json" +DEFAULT_RUNTIME_REGISTRY_PATH = ROOT / "data" / "agent-runtimes.json" +SCHEMA_VERSION = "cento.compute_policy.v1" + +PRESETS: dict[str, dict[str, int]] = { + "codex-first": {"codex": 85, "claude": 15, "openai_api": 0}, + "agent-preferred": {"codex": 55, "claude": 20, "openai_api": 25}, + "balanced": {"codex": 50, "claude": 30, "openai_api": 20}, + "claude-first": {"codex": 20, "claude": 80, "openai_api": 0}, + "api-minimal": {"codex": 70, "claude": 30, "openai_api": 0}, + "api-assisted": {"codex": 50, "claude": 25, "openai_api": 25}, +} + +AGENT_PREFERENCE_POLICY = { + "codex_claude_utilization_threshold_percent": 30, + "eligible_work_agent_preference_percent_range": [70, 80], + "eligible_work_agent_preference_target_percent": 75, + "metered_openai_api_reserved_for": [ + "structured Responses API work", + "image generation", + "ProReq planning", + "other API-only behavior", + ], + "notes": "When Codex/Claude weekly utilization is above 30% and capacity remains usable, prefer agent lanes for roughly 70-80% of eligible non-API-only work.", +} + +DEFAULT_POLICY = { + "schema_version": SCHEMA_VERSION, + "profile": "codex-first", + "providers": { + "codex": { + "share": 85, + "kind": "agent", + "runtime": "codex", + "model": "gpt-5.3-codex-spark", + "cost_mode": "subscription_or_limit", + "enabled": True, + "notes": "Prefer Codex when interactive/agent limit is available.", + }, + "claude": { + "share": 15, + "kind": "agent", + "runtime": "claude-code", + "model": "claude-sonnet-4-6", + "cost_mode": "subscription_or_limit", + "enabled": True, + "notes": "Fallback for agent work when Codex is unavailable or weighted routing selects it.", + }, + "openai_api": { + "share": 0, + "kind": "api", + "runtime": "api-openai", + "model": "${CENTO_OPENAI_WORKER_MODEL}", + "cost_mode": "metered", + "enabled": False, + "notes": "Use only when a pipeline explicitly needs API-only behavior such as structured Responses or image generation.", + }, + }, + "agent_preference_policy": AGENT_PREFERENCE_POLICY, +} + + +class PolicyError(RuntimeError): + pass + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def policy_path() -> Path: + return Path(os.environ.get("CENTO_COMPUTE_POLICY_PATH", DEFAULT_POLICY_PATH)).expanduser() + + +def runtime_registry_path() -> Path: + return Path(os.environ.get("CENTO_AGENT_RUNTIME_CONFIG", DEFAULT_RUNTIME_REGISTRY_PATH)).expanduser() + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + if not isinstance(payload, dict): + raise PolicyError(f"JSON root must be an object: {path}") + return payload + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def validate_share(name: str, value: int) -> int: + if value < 0 or value > 100: + raise PolicyError(f"{name} share must be between 0 and 100") + return value + + +def build_policy(*, profile: str, codex: int, claude: int, openai_api: int) -> dict[str, Any]: + codex = validate_share("codex", codex) + claude = validate_share("claude", claude) + openai_api = validate_share("openai_api", openai_api) + if codex + claude <= 0: + raise PolicyError("at least one agent runtime share is required: codex + claude must be > 0") + policy = json.loads(json.dumps(DEFAULT_POLICY)) + policy["profile"] = profile + policy["updated_at"] = now_iso() + policy["providers"]["codex"]["share"] = codex + policy["providers"]["codex"]["enabled"] = codex > 0 + policy["providers"]["claude"]["share"] = claude + policy["providers"]["claude"]["enabled"] = claude > 0 + policy["providers"]["openai_api"]["share"] = openai_api + policy["providers"]["openai_api"]["enabled"] = openai_api > 0 + policy["agent_runtime_weights"] = { + "codex": codex, + "claude-code": claude, + } + policy["metered_api_policy"] = { + "openai_api_share": openai_api, + "prefer_agent_when_possible": openai_api < max(codex, claude), + "requires_explicit_api_runtime": True, + "agent_preference_policy": AGENT_PREFERENCE_POLICY, + } + policy["agent_preference_policy"] = AGENT_PREFERENCE_POLICY + return policy + + +def load_policy() -> dict[str, Any]: + payload = read_json(policy_path()) + if not payload: + return json.loads(json.dumps(DEFAULT_POLICY)) + if payload.get("schema_version") != SCHEMA_VERSION: + raise PolicyError(f"Unsupported compute policy schema: {payload.get('schema_version')}") + return payload + + +def runtime_entry_updates(policy: dict[str, Any]) -> dict[str, dict[str, Any]]: + providers = policy.get("providers") if isinstance(policy.get("providers"), dict) else {} + codex = providers.get("codex") if isinstance(providers.get("codex"), dict) else {} + claude = providers.get("claude") if isinstance(providers.get("claude"), dict) else {} + return { + "codex": { + "weight": int(codex.get("share") or 0), + "preferred": int(codex.get("share") or 0) >= int(claude.get("share") or 0), + "model": str(codex.get("model") or "gpt-5.3-codex-spark"), + "budget_note": f"Compute policy `{policy.get('profile')}` assigns Codex share {int(codex.get('share') or 0)}.", + }, + "claude-code": { + "weight": int(claude.get("share") or 0), + "preferred": int(claude.get("share") or 0) > int(codex.get("share") or 0), + "model": str(claude.get("model") or "claude-sonnet-4-6"), + "budget_note": f"Compute policy `{policy.get('profile')}` assigns Claude share {int(claude.get('share') or 0)}.", + }, + } + + +def apply_policy(policy: dict[str, Any]) -> dict[str, Any]: + path = runtime_registry_path() + registry = read_json(path) + if not registry: + registry = {"routing": "weighted", "runtimes": []} + runtimes = registry.get("runtimes") + if not isinstance(runtimes, list): + raise PolicyError(f"Runtime registry must include a runtimes list: {path}") + updates = runtime_entry_updates(policy) + seen: set[str] = set() + for entry in runtimes: + if not isinstance(entry, dict): + continue + runtime_id = str(entry.get("id") or "") + if runtime_id not in updates: + continue + seen.add(runtime_id) + entry.update(updates[runtime_id]) + if not entry.get("agent"): + entry["agent"] = "codex" if runtime_id == "codex" else "claude-code" + for runtime_id, update in updates.items(): + if runtime_id in seen: + continue + runtimes.append( + { + "id": runtime_id, + "display_name": "Codex" if runtime_id == "codex" else "Claude Code", + "provider": "openai" if runtime_id == "codex" else "anthropic", + "agent": "codex" if runtime_id == "codex" else "claude-code", + "command_env": "CENTO_CODEX_BIN" if runtime_id == "codex" else "CENTO_CLAUDE_BIN", + "default_binary": "codex" if runtime_id == "codex" else "claude", + **update, + } + ) + registry["routing"] = "weighted" + registry["compute_policy"] = { + "schema_version": SCHEMA_VERSION, + "profile": policy.get("profile"), + "policy_path": str(policy_path()), + "applied_at": now_iso(), + "openai_api_share": policy.get("providers", {}).get("openai_api", {}).get("share", 0), + } + write_json(path, registry) + return registry + + +def summarize(policy: dict[str, Any], registry: dict[str, Any] | None = None) -> dict[str, Any]: + providers = policy.get("providers") if isinstance(policy.get("providers"), dict) else {} + runtime_weights = {} + if registry: + for entry in registry.get("runtimes", []): + if isinstance(entry, dict): + runtime_weights[str(entry.get("id") or "")] = int(entry.get("weight") or 0) + return { + "schema_version": SCHEMA_VERSION, + "policy_path": str(policy_path()), + "runtime_registry_path": str(runtime_registry_path()), + "profile": policy.get("profile", ""), + "provider_shares": {key: int(value.get("share") or 0) for key, value in providers.items() if isinstance(value, dict)}, + "runtime_weights": runtime_weights, + "openai_api_enabled": bool(providers.get("openai_api", {}).get("enabled")) if isinstance(providers.get("openai_api"), dict) else False, + "agent_preference_policy": policy.get("agent_preference_policy", AGENT_PREFERENCE_POLICY), + "recommendation": "Use agent-work auto routing for agent tasks; use api-openai only when the command explicitly requires API-only structured or image behavior.", + } + + +def print_payload(payload: dict[str, Any], *, as_json: bool) -> None: + if as_json: + print(json.dumps(payload, indent=2, sort_keys=False)) + return + print(f"profile: {payload.get('profile', '')}") + print(f"policy: {payload.get('policy_path', '')}") + print(f"runtime registry: {payload.get('runtime_registry_path', '')}") + print("provider shares:") + for key, value in payload.get("provider_shares", {}).items(): + print(f"- {key}: {value}") + if payload.get("runtime_weights"): + print("runtime weights:") + for key, value in payload.get("runtime_weights", {}).items(): + print(f"- {key}: {value}") + preference = payload.get("agent_preference_policy") if isinstance(payload.get("agent_preference_policy"), dict) else {} + if preference: + print( + "agent preference: " + f"{preference.get('eligible_work_agent_preference_target_percent')}% when utilization >= " + f"{preference.get('codex_claude_utilization_threshold_percent')}%" + ) + print(f"openai api enabled: {payload.get('openai_api_enabled')}") + + +def command_show(args: argparse.Namespace) -> int: + policy = load_policy() + registry = read_json(runtime_registry_path()) + print_payload(summarize(policy, registry), as_json=args.json) + return 0 + + +def command_set(args: argparse.Namespace) -> int: + policy = build_policy(profile=args.profile, codex=args.codex, claude=args.claude, openai_api=args.openai_api) + registry = read_json(runtime_registry_path()) + if not args.dry_run: + write_json(policy_path(), policy) + registry = apply_policy(policy) + payload = summarize(policy, registry) + payload["dry_run"] = bool(args.dry_run) + print_payload(payload, as_json=args.json) + return 0 + + +def command_preset(args: argparse.Namespace) -> int: + shares = PRESETS[args.name] + args.profile = args.name + args.codex = shares["codex"] + args.claude = shares["claude"] + args.openai_api = shares["openai_api"] + return command_set(args) + + +def command_apply(args: argparse.Namespace) -> int: + policy = load_policy() + registry = apply_policy(policy) if not args.dry_run else read_json(runtime_registry_path()) + payload = summarize(policy, registry) + payload["dry_run"] = bool(args.dry_run) + print_payload(payload, as_json=args.json) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Manage Cento compute provider shares for Codex, Claude, and metered OpenAI API use.") + sub = parser.add_subparsers(dest="command", required=True) + + show = sub.add_parser("show", help="Show the active compute policy and runtime weights.") + show.add_argument("--json", action="store_true") + show.set_defaults(func=command_show) + + preset = sub.add_parser("preset", help="Apply a named provider-share preset.") + preset.add_argument("name", choices=sorted(PRESETS)) + preset.add_argument("--dry-run", action="store_true") + preset.add_argument("--json", action="store_true") + preset.set_defaults(func=command_preset) + + set_cmd = sub.add_parser("set", help="Set exact provider shares.") + set_cmd.add_argument("--profile", default="custom") + set_cmd.add_argument("--codex", type=int, required=True) + set_cmd.add_argument("--claude", type=int, required=True) + set_cmd.add_argument("--openai-api", type=int, required=True, dest="openai_api") + set_cmd.add_argument("--dry-run", action="store_true") + set_cmd.add_argument("--json", action="store_true") + set_cmd.set_defaults(func=command_set) + + apply_cmd = sub.add_parser("apply", help="Reapply the saved compute policy to the Agent Work runtime registry.") + apply_cmd.add_argument("--dry-run", action="store_true") + apply_cmd.add_argument("--json", action="store_true") + apply_cmd.set_defaults(func=command_apply) + + args = parser.parse_args() + try: + return int(args.func(args)) + except PolicyError as exc: + parser.exit(2, f"[ERROR] {exc}\n") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/crm_module.py b/scripts/crm_module.py index 7dc0d0d..d3cfc0e 100755 --- a/scripts/crm_module.py +++ b/scripts/crm_module.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, unquote, urlparse from urllib.request import Request, urlopen ROOT_DIR = Path(__file__).resolve().parent.parent @@ -27,6 +27,9 @@ INTAKE_DOCS_PATH = ROOT_DIR / "docs" / "career-intake.md" REDMINE_DOCS_PATH = ROOT_DIR / "docs" / "redmine-integration.md" TEMPLATE_DIR = ROOT_DIR / "templates" / "crm" +FOUNDRY_TEMPLATE_ROOT = ROOT_DIR / "templates" / "foundry" +CLIENT_INTAKE_HUB_TEMPLATE_ROOT = FOUNDRY_TEMPLATE_ROOT / "client-intake-hub" +CLIENT_INTAKE_HUB_DOCS_PATH = ROOT_DIR / "docs" / "client-intake-hub.md" CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "cento" REDMINE_CONFIG_PATH = CONFIG_DIR / "redmine.json" LOG_DIR = ROOT_DIR / "logs" / "crm" @@ -1707,6 +1710,54 @@ def read_static_file(name: str) -> bytes: return target.read_bytes() +def repo_rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT_DIR).as_posix() + except ValueError: + return path.as_posix() + + +def safe_foundry_static_path(request_path: str) -> Path: + raw = unquote(request_path.removeprefix("/foundry/")) + rel_path = Path(raw) + if rel_path.is_absolute() or any(part in {"", ".", ".."} for part in rel_path.parts): + raise CRMError("Invalid Foundry asset path.") + target = FOUNDRY_TEMPLATE_ROOT / rel_path + resolved = target.resolve() + root = FOUNDRY_TEMPLATE_ROOT.resolve() + if not resolved.is_relative_to(root) or not resolved.is_file(): + raise FileNotFoundError(raw) + return resolved + + +def foundry_tools_payload() -> dict[str, Any]: + bundle_files = [ + CLIENT_INTAKE_HUB_TEMPLATE_ROOT / "client-intake-hub.html", + CLIENT_INTAKE_HUB_TEMPLATE_ROOT / "client-profile.schema.json", + CLIENT_INTAKE_HUB_TEMPLATE_ROOT / "command-api.json", + CLIENT_INTAKE_HUB_TEMPLATE_ROOT / "storage-leak-policy.json", + CLIENT_INTAKE_HUB_TEMPLATE_ROOT / "validation-plan.json", + CLIENT_INTAKE_HUB_TEMPLATE_ROOT / "README.md", + ] + materialized = all(path.exists() for path in bundle_files) and CLIENT_INTAKE_HUB_DOCS_PATH.exists() + return { + "schema_version": "cento.crm.foundry_tools.v1", + "tools": [ + { + "id": "client-intake-hub", + "title": "Client Intake Hub", + "status": "materialized" if materialized else "not_materialized", + "description": "Fixture-only career consulting intake bundle generated by Cento Tool Foundry.", + "target_root": repo_rel(CLIENT_INTAKE_HUB_TEMPLATE_ROOT), + "docs_path": repo_rel(CLIENT_INTAKE_HUB_DOCS_PATH), + "preview_path": "/foundry/client-intake-hub/client-intake-hub.html", + "privacy": "fixture-only; no real client data", + "files": [repo_rel(path) for path in bundle_files if path.exists()], + } + ], + } + + def content_type_for(path: str) -> str: if path.endswith('.css'): return 'text/css; charset=utf-8' @@ -1734,6 +1785,7 @@ def api_payload(profile_name: str) -> dict[str, Any]: "state_path": str(state_path), "latest_state_path": str(CRM_ROOT / 'latest.json'), }, + "foundry": foundry_tools_payload(), } @@ -1789,6 +1841,9 @@ def do_GET(self) -> None: payload = {"ok": True, "requests": list(REQUEST_LOG)} json_response(self, 200, payload) return + if parsed.path == '/api/foundry/tools': + json_response(self, 200, {"ok": True, **foundry_tools_payload()}) + return if parsed.path in ('/', '/index.html'): body = read_static_file('index.html') file_response(self, 200, body, 'text/html; charset=utf-8') @@ -1805,6 +1860,10 @@ def do_GET(self) -> None: body = DOCS_PATH.read_bytes() file_response(self, 200, body, 'text/markdown; charset=utf-8') return + if parsed.path.startswith('/foundry/'): + target = safe_foundry_static_path(parsed.path) + file_response(self, 200, target.read_bytes(), content_type_for(target.name)) + return json_response(self, 404, {"ok": False, "error": f"Unknown path: {parsed.path}"}) except FileNotFoundError as exc: json_response(self, 404, {"ok": False, "error": f"Missing static asset: {exc}"}) diff --git a/scripts/demo_evidence.py b/scripts/demo_evidence.py new file mode 100755 index 0000000..4b9806b --- /dev/null +++ b/scripts/demo_evidence.py @@ -0,0 +1,606 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import re +import shlex +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parent.parent +SCHEMA_VERSION = "cento.demo_evidence.v1" +DEFAULT_DURATION_SECONDS = 15.0 +MIN_DURATION_SECONDS = 10.0 +MAX_DURATION_SECONDS = 30.0 +DEFAULT_FPS = 15 +DEFAULT_GEOMETRY = "1280x720+0,0" + + +class DemoEvidenceError(RuntimeError): + pass + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def timestamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + + +def repo_path(value: str | Path) -> Path: + path = Path(value).expanduser() + if path.is_absolute(): + return path + return ROOT / path + + +def display_path(path: Path) -> str: + try: + return str(path.resolve().relative_to(ROOT)) + except ValueError: + return str(path) + + +def slugify(value: str) -> str: + text = re.sub(r"[^a-z0-9]+", "-", str(value or "").strip().lower()) + return re.sub(r"-{2,}", "-", text).strip("-") or "demo" + + +def duration_text(value: float) -> str: + if float(value).is_integer(): + return str(int(value)) + return f"{value:.3f}".rstrip("0").rstrip(".") + + +def validate_duration(value: float) -> float: + if value < MIN_DURATION_SECONDS or value > MAX_DURATION_SECONDS: + raise DemoEvidenceError( + f"demo duration must be between {duration_text(MIN_DURATION_SECONDS)} and " + f"{duration_text(MAX_DURATION_SECONDS)} seconds" + ) + return value + + +def parse_geometry(value: str) -> tuple[int, int, int, int]: + match = re.match(r"^(\d+)x(\d+)(?:\+(-?\d+)(?:,|\+)(-?\d+))?$", value.strip()) + if not match: + raise DemoEvidenceError("geometry must look like WIDTHxHEIGHT or WIDTHxHEIGHT+X,Y") + width = int(match.group(1)) + height = int(match.group(2)) + x = int(match.group(3) or 0) + y = int(match.group(4) or 0) + if width <= 0 or height <= 0: + raise DemoEvidenceError("geometry width and height must be positive") + return width, height, x, y + + +def detect_x11_geometry() -> str: + if shutil.which("xrandr"): + proc = subprocess.run(["xrandr", "--current"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + match = re.search(r"current\s+(\d+)\s+x\s+(\d+)", proc.stdout) + if match: + return f"{match.group(1)}x{match.group(2)}+0,0" + if shutil.which("xdpyinfo"): + proc = subprocess.run(["xdpyinfo"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + match = re.search(r"dimensions:\s+(\d+)x(\d+)\s+pixels", proc.stdout) + if match: + return f"{match.group(1)}x{match.group(2)}+0,0" + return DEFAULT_GEOMETRY + + +def sha256_file(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def ffprobe_duration(path: Path) -> float | None: + ffprobe = shutil.which("ffprobe") + if not ffprobe or not path.exists(): + return None + proc = subprocess.run( + [ + ffprobe, + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(path), + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + try: + return float(proc.stdout.strip()) + except ValueError: + return None + + +def default_run_dir(args: argparse.Namespace) -> Path: + if args.out: + return repo_path(args.out) + + stamp = timestamp() + title_slug = slugify(args.task or args.title or "demo") + if args.factory_run: + base = repo_path(args.factory_run) + if args.task: + return base / "tasks" / slugify(args.task) / "evidence" / f"demo-{stamp}" + return base / "evidence" / f"demo-{title_slug}-{stamp}" + return ROOT / "workspace" / "runs" / "demo-evidence" / f"{title_slug}-{stamp}" + + +def choose_recorder(requested: str, *, dry_run: bool = False) -> str: + if requested != "auto": + return requested + + system = platform.system().lower() + if system == "linux": + if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wf-recorder"): + return "wf-recorder" + if os.environ.get("DISPLAY") and shutil.which("ffmpeg"): + return "x11grab" + if dry_run and shutil.which("ffmpeg"): + return "x11grab" + raise DemoEvidenceError("no Linux screen recorder found; install ffmpeg or wf-recorder and set DISPLAY/WAYLAND_DISPLAY") + + if system == "darwin": + if shutil.which("ffmpeg") or dry_run: + return "avfoundation" + raise DemoEvidenceError("macOS demo recording requires ffmpeg with avfoundation support") + + if shutil.which("ffmpeg") or dry_run: + return "synthetic" + raise DemoEvidenceError(f"unsupported platform for screen recording: {platform.system()}") + + +def require_command(command: str, recorder: str) -> None: + if not shutil.which(command): + raise DemoEvidenceError(f"{recorder} recorder requires `{command}`") + + +def build_command(args: argparse.Namespace, video_path: Path, recorder: str) -> dict[str, Any]: + duration = validate_duration(float(args.duration)) + fps = int(args.fps) + if fps <= 0: + raise DemoEvidenceError("--fps must be positive") + + if recorder == "x11grab": + if not args.dry_run: + require_command("ffmpeg", recorder) + if not os.environ.get("DISPLAY"): + raise DemoEvidenceError("x11grab recorder requires DISPLAY") + geometry = args.geometry or detect_x11_geometry() + width, height, x, y = parse_geometry(geometry) + display = os.environ.get("DISPLAY", ":0") + command = [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "warning", + "-f", + "x11grab", + "-draw_mouse", + "1", + "-video_size", + f"{width}x{height}", + "-framerate", + str(fps), + "-i", + f"{display}+{x},{y}", + "-t", + duration_text(duration), + "-an", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + str(video_path), + ] + return {"recorder": recorder, "command": command, "geometry": geometry, "display": display} + + if recorder == "wf-recorder": + if not args.dry_run: + require_command("wf-recorder", recorder) + if not os.environ.get("WAYLAND_DISPLAY"): + raise DemoEvidenceError("wf-recorder requires WAYLAND_DISPLAY") + command = ["wf-recorder", "--file", str(video_path), "--framerate", str(fps)] + return { + "recorder": recorder, + "command": command, + "duration_controller": "cento_terminate_after_duration", + "display": os.environ.get("WAYLAND_DISPLAY", ""), + } + + if recorder == "avfoundation": + if not args.dry_run: + require_command("ffmpeg", recorder) + input_name = args.avfoundation_input or "Capture screen 0" + command = [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "warning", + "-f", + "avfoundation", + "-framerate", + str(fps), + "-capture_cursor", + "1", + "-i", + input_name, + "-t", + duration_text(duration), + "-an", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + str(video_path), + ] + return {"recorder": recorder, "command": command, "display": input_name} + + if recorder == "synthetic": + if not args.dry_run: + require_command("ffmpeg", recorder) + geometry = args.geometry or DEFAULT_GEOMETRY + width, height, _x, _y = parse_geometry(geometry) + command = [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "warning", + "-f", + "lavfi", + "-i", + f"testsrc=size={width}x{height}:rate={fps}", + "-t", + duration_text(duration), + "-an", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + str(video_path), + ] + return {"recorder": recorder, "command": command, "geometry": geometry, "display": "synthetic-testsrc"} + + raise DemoEvidenceError(f"unknown recorder: {recorder}") + + +def run_command(plan: dict[str, Any], duration: float) -> dict[str, Any]: + command = list(plan["command"]) + started = time.monotonic() + if plan["recorder"] == "wf-recorder": + proc = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + stdout, stderr = proc.communicate(timeout=duration) + except subprocess.TimeoutExpired: + proc.terminate() + try: + stdout, stderr = proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate(timeout=5) + elapsed = time.monotonic() - started + return {"returncode": proc.returncode, "stdout": stdout[-4000:], "stderr": stderr[-4000:], "elapsed_seconds": elapsed} + + try: + proc = subprocess.run( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=duration + 30, + check=False, + ) + except subprocess.TimeoutExpired as exc: + return { + "returncode": 124, + "stdout": (exc.stdout or "")[-4000:], + "stderr": (exc.stderr or f"timed out after {duration + 30:.1f}s")[-4000:], + "elapsed_seconds": time.monotonic() - started, + "timed_out": True, + } + return { + "returncode": proc.returncode, + "stdout": proc.stdout[-4000:], + "stderr": proc.stderr[-4000:], + "elapsed_seconds": time.monotonic() - started, + } + + +def write_summary(run_dir: Path, receipt: dict[str, Any]) -> Path: + summary = run_dir / "summary.md" + artifacts = receipt.get("artifacts", {}) + lines = [ + "# Demo Evidence", + "", + f"- Status: `{receipt.get('status', 'unknown')}`", + f"- Title: {receipt.get('title') or 'demo'}", + f"- Created: `{receipt.get('created_at')}`", + f"- Recorder: `{receipt.get('recorder')}`", + f"- Requested duration: `{receipt.get('duration_seconds_requested')}` seconds", + ] + measured = receipt.get("duration_seconds_measured") + if measured is not None: + lines.append(f"- Measured duration: `{measured}` seconds") + if receipt.get("factory_run"): + lines.append(f"- Factory run: `{receipt['factory_run']}`") + if receipt.get("task"): + lines.append(f"- Task: `{receipt['task']}`") + if receipt.get("worker"): + lines.append(f"- Worker: `{receipt['worker']}`") + lines.extend( + [ + f"- Video: `{artifacts.get('video', '')}`", + f"- Receipt: `{artifacts.get('receipt', '')}`", + "", + "## Notes", + "", + ] + ) + notes = receipt.get("notes") or [] + if notes: + lines.extend(f"- {note}" for note in notes) + else: + lines.append("- none") + lines.extend(["", "## Verification", "", f"`cento demo-evidence verify {display_path(run_dir)}`"]) + if receipt.get("planned_command"): + lines.extend(["", "## Planned Command", "", "```bash", receipt["planned_command"], "```"]) + summary.write_text("\n".join(lines).rstrip() + "\n") + return summary + + +def base_receipt(args: argparse.Namespace, run_dir: Path, video_path: Path, recorder: str, plan: dict[str, Any]) -> dict[str, Any]: + receipt_path = run_dir / "receipt.json" + return { + "schema_version": SCHEMA_VERSION, + "status": "planned", + "created_at": now_iso(), + "title": args.title, + "factory_run": args.factory_run or "", + "task": args.task or "", + "worker": args.worker or "", + "tags": args.tag or [], + "notes": args.notes or [], + "duration_seconds_requested": float(args.duration), + "duration_window_seconds": {"min": MIN_DURATION_SECONDS, "max": MAX_DURATION_SECONDS}, + "fps": int(args.fps), + "platform": {"system": platform.system(), "release": platform.release(), "machine": platform.machine()}, + "recorder": recorder, + "display": plan.get("display", ""), + "geometry": plan.get("geometry", ""), + "planned_command": shlex.join(plan["command"]), + "artifacts": { + "run_dir": display_path(run_dir), + "video": display_path(video_path), + "receipt": display_path(receipt_path), + "summary": display_path(run_dir / "summary.md"), + }, + } + + +def write_receipt(run_dir: Path, receipt: dict[str, Any]) -> Path: + path = run_dir / "receipt.json" + path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n") + return path + + +def command_record(args: argparse.Namespace) -> int: + validate_duration(float(args.duration)) + run_dir = default_run_dir(args) + run_dir.mkdir(parents=True, exist_ok=True) + video_path = run_dir / args.video_name + recorder = choose_recorder(args.recorder, dry_run=args.dry_run) + plan = build_command(args, video_path, recorder) + receipt = base_receipt(args, run_dir, video_path, recorder, plan) + + if args.dry_run: + receipt.update({"status": "dry_run", "ok": True, "message": "recording command planned; video was not captured"}) + write_summary(run_dir, receipt) + write_receipt(run_dir, receipt) + print_result(receipt, args.json) + return 0 + + result = run_command(plan, float(args.duration)) + measured = ffprobe_duration(video_path) + receipt["duration_seconds_measured"] = measured + receipt["recording"] = result + + if result["returncode"] == 0 and video_path.exists() and video_path.stat().st_size > 0: + receipt.update( + { + "status": "passed", + "ok": True, + "message": "demo captured", + "video_bytes": video_path.stat().st_size, + "video_sha256": sha256_file(video_path), + } + ) + else: + receipt.update({"status": "failed", "ok": False, "message": "recording command failed or produced no video"}) + + write_summary(run_dir, receipt) + write_receipt(run_dir, receipt) + print_result(receipt, args.json) + return 0 if receipt.get("ok") else 1 + + +def receipt_for_path(path_value: str | None) -> Path: + if path_value: + path = repo_path(path_value) + if path.is_dir(): + return path / "receipt.json" + return path + + base = ROOT / "workspace" / "runs" / "demo-evidence" + receipts = sorted(base.glob("*/receipt.json"), key=lambda item: item.stat().st_mtime, reverse=True) + if not receipts: + raise DemoEvidenceError("no demo evidence receipts found under workspace/runs/demo-evidence") + return receipts[0] + + +def verify_receipt(receipt_path: Path) -> dict[str, Any]: + if not receipt_path.exists(): + return {"ok": False, "receipt": display_path(receipt_path), "checks": [{"name": "receipt exists", "ok": False}]} + + receipt = json.loads(receipt_path.read_text()) + artifacts = receipt.get("artifacts") or {} + video_value = artifacts.get("video") or "" + video_path = repo_path(video_value) if video_value else receipt_path.parent / "demo.mp4" + checks: list[dict[str, Any]] = [] + + def add(name: str, ok: bool, detail: str = "") -> None: + checks.append({"name": name, "ok": ok, "detail": detail}) + + add("receipt status passed", receipt.get("status") == "passed", str(receipt.get("status", ""))) + add("video exists", video_path.exists(), display_path(video_path)) + add("video nonempty", video_path.exists() and video_path.stat().st_size > 0, str(video_path.stat().st_size if video_path.exists() else 0)) + + measured = ffprobe_duration(video_path) + if measured is None: + measured = receipt.get("duration_seconds_measured") + if measured is None: + add("duration readable", False, "ffprobe duration unavailable") + else: + in_window = MIN_DURATION_SECONDS <= float(measured) <= MAX_DURATION_SECONDS + 0.75 + add("duration in 10-30s window", in_window, duration_text(float(measured))) + + expected_hash = receipt.get("video_sha256") + if expected_hash and video_path.exists(): + actual_hash = sha256_file(video_path) + add("sha256 matches receipt", actual_hash == expected_hash, actual_hash) + elif expected_hash: + add("sha256 matches receipt", False, "video missing") + + ok = all(item["ok"] for item in checks) + return { + "ok": ok, + "receipt": display_path(receipt_path), + "video": display_path(video_path), + "checks": checks, + "summary": "demo evidence verified" if ok else "demo evidence verification failed", + } + + +def command_verify(args: argparse.Namespace) -> int: + result = verify_receipt(receipt_for_path(args.path)) + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(result["summary"]) + for check in result["checks"]: + marker = "PASS" if check["ok"] else "FAIL" + detail = f" - {check['detail']}" if check.get("detail") else "" + print(f"{marker} {check['name']}{detail}") + return 0 if result["ok"] else 1 + + +def command_status(args: argparse.Namespace) -> int: + receipt_path = receipt_for_path(args.path) + receipt = json.loads(receipt_path.read_text()) + if args.json: + print(json.dumps(receipt, indent=2, sort_keys=True)) + else: + print(f"{receipt.get('status', 'unknown')} {receipt.get('title', '')}") + print(f"run: {receipt.get('artifacts', {}).get('run_dir', display_path(receipt_path.parent))}") + print(f"video: {receipt.get('artifacts', {}).get('video', '')}") + print(f"receipt: {display_path(receipt_path)}") + return 0 if receipt.get("status") == "passed" else 1 + + +def print_result(receipt: dict[str, Any], as_json: bool) -> None: + if as_json: + print(json.dumps(receipt, indent=2, sort_keys=True)) + return + print(f"demo evidence {receipt.get('status')}: {receipt.get('message', '')}") + print(f"run: {receipt.get('artifacts', {}).get('run_dir', '')}") + print(f"video: {receipt.get('artifacts', {}).get('video', '')}") + print(f"receipt: {receipt.get('artifacts', {}).get('receipt', '')}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Record short 10-30 second demo videos as Cento evidence.") + sub = parser.add_subparsers(dest="command", required=True) + + record = sub.add_parser("record", help="Record a short demo clip and receipt.") + record.add_argument("--title", default="Short demo evidence", help="Human-readable evidence title.") + record.add_argument("--duration", type=float, default=DEFAULT_DURATION_SECONDS, help="Clip length in seconds; must be 10-30.") + record.add_argument("--fps", type=int, default=DEFAULT_FPS, help="Capture frame rate.") + record.add_argument("--geometry", help="Capture region as WIDTHxHEIGHT+X,Y. Defaults to detected screen size.") + record.add_argument("--out", help="Output run directory. Defaults to workspace/runs/demo-evidence or Factory evidence path.") + record.add_argument("--factory-run", help="Factory run directory, such as workspace/runs/factory/.") + record.add_argument("--task", help="Factory task id or worker task id.") + record.add_argument("--worker", help="Worker id or local agent label.") + record.add_argument("--notes", action="append", default=[], help="Evidence note. Can be passed multiple times.") + record.add_argument("--tag", action="append", default=[], help="Evidence tag. Can be passed multiple times.") + record.add_argument("--video-name", default="demo.mp4", help="Video file name inside the run directory.") + record.add_argument( + "--recorder", + choices=["auto", "x11grab", "wf-recorder", "avfoundation", "synthetic"], + default="auto", + help="Recorder backend. Synthetic is only for smoke tests, not product evidence.", + ) + record.add_argument("--avfoundation-input", help="macOS ffmpeg avfoundation input name.") + record.add_argument("--dry-run", action="store_true", help="Write receipt and summary without recording video.") + record.add_argument("--json", action="store_true", help="Print JSON receipt.") + record.set_defaults(func=command_record) + + verify = sub.add_parser("verify", help="Verify a demo evidence receipt and video.") + verify.add_argument("path", nargs="?", help="Run directory or receipt.json. Defaults to latest workspace/runs/demo-evidence receipt.") + verify.add_argument("--json", action="store_true", help="Print JSON verification result.") + verify.set_defaults(func=command_verify) + + status = sub.add_parser("status", help="Print a demo evidence receipt status.") + status.add_argument("path", nargs="?", help="Run directory or receipt.json. Defaults to latest workspace/runs/demo-evidence receipt.") + status.add_argument("--json", action="store_true", help="Print full JSON receipt.") + status.set_defaults(func=command_status) + + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + try: + return args.func(args) + except DemoEvidenceError as exc: + print(f"demo-evidence: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev_pipeline_hard_proreq.py b/scripts/dev_pipeline_hard_proreq.py new file mode 100644 index 0000000..b460229 --- /dev/null +++ b/scripts/dev_pipeline_hard_proreq.py @@ -0,0 +1,1677 @@ +#!/usr/bin/env python3 +"""Generate hard-proreq pipeline artifacts for Dev Pipeline Studio runs.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import spend_ledger + + +ROOT = Path(__file__).resolve().parents[1] +PIPELINE_ROOT = Path(os.environ.get("CENTO_DEV_PIPELINE_STUDIO_ROOT", ROOT / "workspace" / "runs" / "dev-pipeline-studio" / "docs-pages" / "latest")) +RESPONSES_URL = "https://api.openai.com/v1/responses" +IMAGE_EDITS_URL = "https://api.openai.com/v1/images/edits" +MODELS_URL = "https://api.openai.com/v1/models" +STORY_COUNT = int(os.environ.get("CENTO_HARD_PROREQ_STORY_COUNT", "10")) +INTEGRATION_MODEL_CEILING = os.environ.get("CENTO_PIPELINE_INTEGRATION_MODEL_CEILING", "gpt-4.1-mini") +BUDGET_TARGET_USD = float(os.environ.get("CENTO_PIPELINE_DELIVERY_BUDGET_USD", "10.00")) +BUDGET_MAX_USD = float(os.environ.get("CENTO_PIPELINE_DELIVERY_MAX_BUDGET_USD", "20.00")) + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + if not isinstance(payload, dict): + return {} + return payload + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def run_payload() -> dict[str, Any]: + return read_json(PIPELINE_ROOT / "execution" / "execution_run.json") + + +def run_id() -> str: + payload = run_payload() + return str(payload.get("run_id") or "manual-hard-proreq") + + +def artifact_dirs() -> tuple[Path, Path]: + current = PIPELINE_ROOT / "execution" / "hard-proreq" / run_id() + latest = PIPELINE_ROOT / "execution" / "hard-proreq" / "latest" + current.mkdir(parents=True, exist_ok=True) + latest.mkdir(parents=True, exist_ok=True) + return current, latest + + +def hard_proreq_spend_ledgers() -> list[Path]: + current, _latest = artifact_dirs() + paths = [current / "spend-ledger.jsonl"] + walk_run_dir = os.environ.get("CENTO_WALK_AUTOPILOT_RUN_DIR", "").strip() + if walk_run_dir: + paths.append(Path(walk_run_dir) / "spend-ledger.jsonl") + return paths + + +def append_spend(record: dict[str, Any]) -> None: + spend_ledger.append_records(hard_proreq_spend_ledgers(), record) + + +def append_api_spend( + *, + lane: str, + category: str, + model: str, + status: str, + usage: dict[str, Any] | None = None, + response_id: str = "", + response: dict[str, Any] | None = None, + artifact: str = "", + note: str = "", + cost_accuracy: str = "", +) -> None: + append_spend( + spend_ledger.build_api_record( + run_id=run_id(), + lane=lane, + category=category, + model=model, + status=status, + usage=usage or {}, + response_id=response_id, + response=response, + artifact=artifact, + note=note, + cost_accuracy=cost_accuracy, + ) + ) + + +def env_bool(name: str) -> bool: + return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} + + +def env_float(name: str) -> float | None: + value = os.environ.get(name, "").strip() + if not value: + return None + try: + return float(value) + except ValueError: + return None + + +def metered_api_budget_gate() -> dict[str, Any]: + if not env_bool("CENTO_REQUIRE_DASHBOARD_TOTAL_BUDGET"): + return {"allowed": True, "status": "not-required"} + dashboard_total = env_float("CENTO_OPENAI_DASHBOARD_TOTAL_SPEND_USD") + hard_cap = env_float("CENTO_OPENAI_HARD_CAP_USD") + if dashboard_total is None: + return { + "allowed": False, + "status": "blocked", + "reason": "CENTO_REQUIRE_DASHBOARD_TOTAL_BUDGET=1 but CENTO_OPENAI_DASHBOARD_TOTAL_SPEND_USD is not set.", + "dashboard_total_spend_usd": None, + "hard_cap_usd": hard_cap, + } + if hard_cap is None: + return { + "allowed": False, + "status": "blocked", + "reason": "CENTO_REQUIRE_DASHBOARD_TOTAL_BUDGET=1 but CENTO_OPENAI_HARD_CAP_USD is not set.", + "dashboard_total_spend_usd": dashboard_total, + "hard_cap_usd": None, + } + if dashboard_total >= hard_cap: + return { + "allowed": False, + "status": "blocked", + "reason": f"OpenAI dashboard total ${dashboard_total:.2f} is already >= hard cap ${hard_cap:.2f}.", + "dashboard_total_spend_usd": dashboard_total, + "hard_cap_usd": hard_cap, + } + return { + "allowed": True, + "status": "allowed", + "dashboard_total_spend_usd": dashboard_total, + "hard_cap_usd": hard_cap, + } + + +def is_timeout_exception(exc: BaseException) -> bool: + if isinstance(exc, (TimeoutError, socket.timeout)): + return True + if isinstance(exc, urllib.error.URLError): + return isinstance(exc.reason, (TimeoutError, socket.timeout)) + return False + + +def write_run_artifact(name: str, payload: dict[str, Any]) -> str: + current, latest = artifact_dirs() + payload = {**payload, "written_at": now_iso()} + current_path = current / name + latest_path = latest / name + write_json(current_path, payload) + write_json(latest_path, payload) + return rel(current_path) + + +def write_run_artifact_path(relative_name: str, payload: dict[str, Any]) -> str: + current, latest = artifact_dirs() + payload = {**payload, "written_at": now_iso()} + current_path = current / relative_name + latest_path = latest / relative_name + write_json(current_path, payload) + write_json(latest_path, payload) + return rel(current_path) + + +def slugify(value: str, fallback: str = "story") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return slug[:64] or fallback + + +def copy_run_file(name: str, source: Path) -> str: + current, latest = artifact_dirs() + current_path = current / name + latest_path = latest / name + current_path.parent.mkdir(parents=True, exist_ok=True) + latest_path.parent.mkdir(parents=True, exist_ok=True) + if source.resolve() != current_path.resolve(): + shutil.copyfile(source, current_path) + if source.resolve() != latest_path.resolve(): + shutil.copyfile(source, latest_path) + return rel(current_path) + + +def write_run_bytes(name: str, payload: bytes) -> str: + current, latest = artifact_dirs() + current_path = current / name + latest_path = latest / name + current_path.parent.mkdir(parents=True, exist_ok=True) + latest_path.parent.mkdir(parents=True, exist_ok=True) + current_path.write_bytes(payload) + latest_path.write_bytes(payload) + return rel(current_path) + + +def write_run_text(name: str, payload: str) -> str: + current, latest = artifact_dirs() + current_path = current / name + latest_path = latest / name + current_path.parent.mkdir(parents=True, exist_ok=True) + latest_path.parent.mkdir(parents=True, exist_ok=True) + current_path.write_text(payload, encoding="utf-8") + latest_path.write_text(payload, encoding="utf-8") + return rel(current_path) + + +def operator_prompt() -> str: + payload = run_payload() + prompt = "\n\n".join( + part.strip() + for part in [ + str(payload.get("issue_subject") or ""), + str(payload.get("prompt") or ""), + ] + if part and part.strip() + ) + return prompt or "Manual hard proreq run from Dev Pipeline Studio." + + +def image_focus_prompt() -> str: + return os.environ.get("CENTO_HARD_PROREQ_IMAGE_TASK", "").strip() or operator_prompt() + + +def reference_screenshot() -> tuple[str, Path | None]: + screenshot_input = run_input("ui-screenshot-request") + input_refs = screenshot_input.get("image_refs") if isinstance(screenshot_input.get("image_refs"), list) else [] + candidates = [ + os.environ.get("CENTO_HARD_PROREQ_REFERENCE_SCREENSHOT", ""), + *[str(item) for item in input_refs if isinstance(item, str) and item.strip()], + "workspace/runs/agent-work/dev-pipeline-studio-execution-flow/input-sequence-list-wide.png", + "workspace/runs/agent-work/dev-pipeline-studio-execution-flow/execution-flow.png", + "workspace/runs/agent-work/dev-pipeline-studio-execution-flow/input-sequence-list.png", + ] + for candidate in candidates: + if not candidate: + continue + path = Path(candidate) + if not path.is_absolute(): + path = ROOT / candidate + if path.exists() and path.is_file(): + return copy_run_file("existing_ui_reference.png", path), path + return "", None + + +def build_integrator_image_prompt(focus: str) -> str: + return ( + "Using the supplied Cento Dev Pipeline Studio screenshot only as the visual style reference, " + "generate a new product UI screenshot that documents the Integrator part of the pipeline. " + "Keep the same dark industrial UI language, orange/cyan/purple accents, dense operational layout, " + "thin borders, compact controls, and non-marketing console feel. " + "The screenshot should clearly show an Integrator lane where backend workstreams converge into one serialized integration step, " + "then flow into deterministic validation and evidence handoff. " + "Include visible labels: Integrator, Serialized integration, Backend work manifest, Integration plan, Validation gates, Evidence handoff. " + "Make it look like an actual in-app screenshot, not a presentation slide. " + f"Specific user request: {focus[:1200]}" + ) + + +def run_input(input_id: str) -> dict[str, Any]: + payload = run_payload() + for item in payload.get("inputs") or []: + if isinstance(item, dict) and str(item.get("id") or "") == input_id: + return item + return {} + + +def image_lane_is_automated() -> bool: + item = run_input("ui-screenshot-request") + if not item: + return True + return str(item.get("source") or "").lower() == "auto" and str(item.get("automation") or item.get("automation_source") or "").lower() in {"openai-image", "image", "screenshot"} + + +def square_reference_image(reference_path: Path) -> Path | None: + try: + from PIL import Image + except Exception: + return None + current, latest = artifact_dirs() + current_path = current / "existing_ui_reference_square.png" + latest_path = latest / "existing_ui_reference_square.png" + with Image.open(reference_path) as image: + image = image.convert("RGBA") + image.thumbnail((1024, 1024), Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (1024, 1024), (9, 9, 9, 255)) + canvas.paste(image, ((1024 - image.width) // 2, (1024 - image.height) // 2)) + current_path.parent.mkdir(parents=True, exist_ok=True) + latest_path.parent.mkdir(parents=True, exist_ok=True) + canvas.save(current_path, "PNG") + canvas.save(latest_path, "PNG") + return current_path + + +def image2_preflight(requests_module: Any, model: str) -> dict[str, Any]: + if model != "gpt-image-2": + return {"status": "not-required", "requested_model": model, "selected_model": model, "fallback_used": False} + if os.environ.get("CENTO_HARD_PROREQ_DISABLE_GPT_IMAGE_2", "0").lower() not in {"0", "false", "no", "off"}: + return { + "status": "disabled", + "requested_model": model, + "selected_model": "gpt-image-1", + "fallback_used": True, + "reason": "gpt-image-2 is disabled until a capability check passes.", + } + try: + response = requests_module.get( + f"{MODELS_URL}/gpt-image-2", + headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"}, + timeout=int(os.environ.get("CENTO_HARD_PROREQ_IMAGE_PREFLIGHT_TIMEOUT", "12")), + ) + except Exception as exc: + return { + "status": "check-failed", + "requested_model": model, + "selected_model": "gpt-image-1", + "fallback_used": True, + "reason": f"{type(exc).__name__}: {exc}", + } + if getattr(response, "status_code", 0) == 200: + return {"status": "passed", "requested_model": model, "selected_model": model, "fallback_used": False} + reason = "" + try: + payload = response.json() + reason = json.dumps(payload, sort_keys=True)[:1000] + except Exception: + reason = str(getattr(response, "text", ""))[:1000] + return { + "status": "blocked", + "requested_model": model, + "selected_model": "gpt-image-1", + "fallback_used": True, + "http_status": getattr(response, "status_code", 0), + "reason": reason or "gpt-image-2 capability check did not pass.", + } + + +def image2_org_verification_blocked(status_code: int, payload: dict[str, Any]) -> bool: + if status_code != 403: + return False + text = json.dumps(payload, sort_keys=True).lower() + return "verify" in text or "verification" in text or "organization" in text or "org" in text + + +def dispatch_image_generation(request: dict[str, Any], reference_path: Path | None) -> dict[str, Any]: + def skipped(reason: str, *, code: str) -> dict[str, Any]: + response_record = { + "schema_version": "cento.hard_proreq.image_response.v1", + "run_id": run_id(), + "status": "skipped", + "skip_code": code, + "lane": "frontend-muted", + "blocking": False, + "error": reason, + "model": str(request.get("model") or ""), + } + write_run_artifact("image_generation_response.json", response_record) + return response_record + + if not reference_path: + return skipped("No existing UI reference screenshot was available.", code="missing-reference-image") + if not os.environ.get("OPENAI_API_KEY"): + return skipped("OPENAI_API_KEY is not configured.", code="missing-openai-api-key") + try: + import requests + except Exception as exc: + return skipped(f"requests import failed: {exc}", code="requests-unavailable") + budget_gate = metered_api_budget_gate() + if not bool(budget_gate.get("allowed")): + write_run_artifact("image_generation_budget_gate.json", budget_gate) + append_api_spend( + lane="image", + category="image", + model=str(request.get("model") or ""), + status="skipped", + artifact="image_generation_budget_gate.json", + note=str(budget_gate.get("reason") or "dashboard budget gate blocked image generation"), + cost_accuracy="budget-gated", + ) + return skipped(str(budget_gate.get("reason") or "Dashboard budget gate blocked image generation."), code="dashboard-budget-gate") + + params = request.get("parameters") if isinstance(request.get("parameters"), dict) else {} + + def post_edit(data: dict[str, str], image_path: Path) -> tuple[int, dict[str, Any]]: + with image_path.open("rb") as handle: + response = requests.post( + IMAGE_EDITS_URL, + headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"}, + data=data, + files=[("image[]", (image_path.name, handle, "image/png"))], + timeout=int(os.environ.get("CENTO_HARD_PROREQ_IMAGE_TIMEOUT", "240")), + ) + try: + payload = response.json() + except Exception: + payload = {"raw": response.text[:4000]} + return response.status_code, payload + + data = { + "model": str(request.get("model") or "gpt-image-2"), + "prompt": str(request.get("prompt") or ""), + "size": str(params.get("size") or "1024x1536"), + "quality": str(params.get("quality") or "low"), + "n": str(params.get("n") or 1), + "output_format": str(params.get("output_format") or "png"), + } + preflight = image2_preflight(requests, data["model"]) + data["model"] = str(preflight.get("selected_model") or data["model"]) + if data["model"] != "gpt-image-2": + data["input_fidelity"] = str(params.get("input_fidelity") or "high") + append_api_spend( + lane="image", + category="image", + model=data["model"], + status="started", + artifact="image_generation_request.json", + note=f"image edit attempt starting; preflight={preflight.get('status')}", + ) + status_code, payload = post_edit(data, reference_path) + append_api_spend( + lane="image", + category="image", + model=data["model"], + status="completed" if status_code < 400 else "failed", + usage=payload.get("usage") if isinstance(payload.get("usage"), dict) else {}, + response=payload if isinstance(payload, dict) else {}, + artifact="image_generation_response.json", + note=f"image edit attempt; preflight={preflight.get('status')}", + ) + attempts = [ + { + "model": data["model"], + "http_status": status_code, + "status": "completed" if status_code < 400 else "failed", + "preflight": preflight, + } + ] + if data["model"] == "gpt-image-2" and image2_org_verification_blocked(status_code, payload): + fallback_data = {**data, "model": "gpt-image-1", "input_fidelity": str(params.get("input_fidelity") or "high")} + append_api_spend( + lane="image", + category="image", + model=fallback_data["model"], + status="started", + artifact="image_generation_request.json", + note="image edit fallback starting after gpt-image-2 org verification block", + ) + status_code, payload = post_edit(fallback_data, reference_path) + data = fallback_data + attempts.append( + { + "model": data["model"], + "http_status": status_code, + "status": "completed" if status_code < 400 else "failed", + "fallback_reason": "gpt-image-2 returned an org verification 403", + } + ) + append_api_spend( + lane="image", + category="image", + model=data["model"], + status="completed" if status_code < 400 else "failed", + usage=payload.get("usage") if isinstance(payload.get("usage"), dict) else {}, + response=payload if isinstance(payload, dict) else {}, + artifact="image_generation_response.json", + note="image edit fallback attempt after gpt-image-2 org verification block", + ) + if status_code >= 400: + response_record = { + "schema_version": "cento.hard_proreq.image_response.v1", + "run_id": run_id(), + "status": "failed", + "http_status": status_code, + "model": data.get("model"), + "requested_model": str(request.get("model") or ""), + "preflight": preflight, + "attempts": attempts, + "response": payload, + } + write_run_artifact("image_generation_response.json", response_record) + return response_record + + image_b64 = "" + data_items = payload.get("data") if isinstance(payload.get("data"), list) else [] + if data_items and isinstance(data_items[0], dict): + image_b64 = str(data_items[0].get("b64_json") or "") + output_rel = "" + if image_b64: + output_rel = write_run_bytes("generated_integrator_screenshot.png", base64.b64decode(image_b64)) + response_record = { + "schema_version": "cento.hard_proreq.image_response.v1", + "run_id": run_id(), + "status": "completed" if output_rel else "failed", + "http_status": status_code, + "model": data["model"], + "requested_model": str(request.get("model") or ""), + "preflight": preflight, + "attempts": attempts, + "output_image": output_rel, + "usage": payload.get("usage") if isinstance(payload.get("usage"), dict) else {}, + "created": payload.get("created"), + "data_count": len(data_items), + "response_without_image": {key: value for key, value in payload.items() if key != "data"}, + } + write_run_artifact("image_generation_response.json", response_record) + return response_record + + +def output_schema() -> dict[str, Any]: + text = {"type": "string"} + text_array = {"type": "array", "items": {"type": "string"}} + workstream = { + "type": "object", + "properties": { + "id": text, + "title": text, + "intent": text, + "owned_paths": text_array, + "read_paths": text_array, + "depends_on": text_array, + "validation_commands": text_array, + "handoff_artifacts": text_array, + }, + "required": ["id", "title", "intent", "owned_paths", "read_paths", "depends_on", "validation_commands", "handoff_artifacts"], + "additionalProperties": False, + } + return { + "type": "object", + "properties": { + "schema_version": {"type": "string", "enum": ["cento.hard_proreq_backend_plan.v1"]}, + "summary": text, + "backend_workstreams": {"type": "array", "items": workstream}, + "integration_plan": text_array, + "validation_plan": text_array, + "parallelization_notes": text_array, + "codex_exec_prompts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": text, + "prompt": text, + "output_schema": text, + }, + "required": ["id", "prompt", "output_schema"], + "additionalProperties": False, + }, + }, + "risks": text_array, + }, + "required": ["schema_version", "summary", "backend_workstreams", "integration_plan", "validation_plan", "parallelization_notes", "codex_exec_prompts", "risks"], + "additionalProperties": False, + } + + +def bounded_command(command: list[str], timeout: int = 8, limit: int = 8000) -> dict[str, Any]: + try: + result = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, check=False) + except Exception as exc: + return {"command": command, "exit_code": 1, "stdout": "", "stderr": str(exc)} + return { + "command": command, + "exit_code": result.returncode, + "stdout": (result.stdout or "")[-limit:], + "stderr": (result.stderr or "")[-limit:], + } + + +def search_terms(prompt: str) -> list[str]: + candidates = re.findall(r"[A-Za-z][A-Za-z0-9_-]{3,}", prompt.lower()) + stop = {"this", "that", "with", "from", "have", "should", "would", "could", "pipeline", "project"} + terms = ["hard-proreq", "dev-pipeline", "agent_work_app", "cento_openai_worker"] + for item in candidates: + if item not in stop and item not in terms: + terms.append(item) + if len(terms) >= 10: + break + return terms + + +def command_intake(_args: argparse.Namespace) -> int: + payload = run_payload() + prompt = operator_prompt() + write_run_artifact( + "operator_intake.json", + { + "schema_version": "cento.hard_proreq.operator_intake.v1", + "run_id": run_id(), + "issue_id": str(payload.get("issue_id") or ""), + "issue_subject": str(payload.get("issue_subject") or ""), + "triggered_by": str(payload.get("triggered_by") or ""), + "operator_prompt": prompt, + "questionnaire_answers": [], + "source": "run_pipeline_contract" if str(payload.get("triggered_by") or "") == "pipeline-run-api" else ("taskstream_issue" if payload.get("issue_id") else "manual_rerun"), + }, + ) + return 0 + + +def command_context(_args: argparse.Namespace) -> int: + prompt = operator_prompt() + terms = search_terms(prompt) + rg_hits: list[dict[str, Any]] = [] + for term in terms[:5]: + result = bounded_command(["rg", "-n", "--glob", "!**/node_modules/**", term, "scripts", "templates", "docs", "data", "tests"], timeout=3, limit=3000) + rg_hits.append({"term": term, "exit_code": result["exit_code"], "matches": result["stdout"].splitlines()[:20]}) + write_run_artifact( + "mini_cento_context.json", + { + "schema_version": "cento.hard_proreq.mini_context.v1", + "run_id": run_id(), + "prompt_terms": terms, + "cento_context": bounded_command(["cento", "gather-context", "--no-remote"], timeout=10, limit=7000), + "cento_tools": bounded_command(["cento", "tools"], timeout=8, limit=7000), + "repo_search": rg_hits, + "summary": [ + "Use Cento-native tools before creating work.", + "Use Taskstream/agent-work for backend work decomposition.", + "Use OpenAI Responses strict JSON Schema for GPT pro backend planning.", + "Keep UI screenshot generation in a separate muted frontend lane.", + ], + }, + ) + return 0 + + +def write_screenshot_request(*, allow_dispatch: bool = True, disabled_reason: str = "") -> int: + prompt = operator_prompt() + focus = image_focus_prompt() + reference_rel, reference_path = reference_screenshot() + image_prompt = build_integrator_image_prompt(focus) + image_request = { + "schema_version": "cento.hard_proreq.image_generation_request.v1", + "endpoint": "POST /v1/images/edits", + "api_surface": "OpenAI Image API", + "model": os.environ.get("CENTO_OPENAI_IMAGE_MODEL", "gpt-image-2"), + "reference_images": [reference_rel] if reference_rel else [], + "prompt": image_prompt, + "parameters": { + "size": os.environ.get("CENTO_HARD_PROREQ_IMAGE_SIZE", "1024x1536"), + "quality": os.environ.get("CENTO_HARD_PROREQ_IMAGE_QUALITY", "low"), + "output_format": "png", + "input_fidelity": "high", + "n": 1, + }, + "target_artifact": "generated_integrator_screenshot.png", + } + image_request_rel = write_run_artifact("image_generation_request.json", image_request) + image_generation_status = { + "schema_version": "cento.hard_proreq.image_response.v1", + "run_id": run_id(), + "status": "skipped", + "skip_code": "image-lane-not-automated", + "lane": "frontend-muted", + "blocking": False, + "error": "The ui-screenshot-request input is not configured as source=auto automation=openai-image.", + "model": str(image_request.get("model") or ""), + } + if image_lane_is_automated() and allow_dispatch: + image_generation_status = dispatch_image_generation(image_request, reference_path) + else: + if image_lane_is_automated() and not allow_dispatch: + image_generation_status = { + **image_generation_status, + "skip_code": "image-lane-muted-by-proreq-light", + "error": disabled_reason or "Image API dispatch is disabled for this route.", + } + write_run_artifact("image_generation_response.json", image_generation_status) + write_run_artifact( + "ui_screenshot_request.json", + { + "schema_version": "cento.hard_proreq.ui_screenshot_request.v1", + "run_id": run_id(), + "status": "muted", + "lane": "frontend-separate", + "reference_screenshot": reference_rel, + "image_generation_request": image_request_rel, + "image_generation_status": image_generation_status, + "request_prompt": ( + "Use the existing UI screenshot as visual context, then generate a new UI screenshot for the requested pipeline documentation. " + "Split the screenshot output into independently validatable regions, each with visible acceptance checks. " + "Do not assign backend architecture or data-flow decisions to this lane.\n\n" + f"Operator request:\n{prompt}\n\nImage generation prompt:\n{image_prompt}" + ), + "parallel_chunks": [ + {"id": "reference-style", "validation": "Existing UI reference screenshot is attached and visible to the image request."}, + {"id": "integrator-lane", "validation": "Generated screenshot documents the serialized Integrator lane and convergence from backend workstreams."}, + {"id": "validation-and-evidence", "validation": "Generated screenshot shows Integrator output flowing into validation gates and evidence handoff."}, + ], + "muted_reason": "Frontend screenshot generation is separate from GPT pro backend planning.", + }, + ) + return 0 + + +def command_screenshot(_args: argparse.Namespace) -> int: + return write_screenshot_request(allow_dispatch=True) + + +def command_light_screenshot(_args: argparse.Namespace) -> int: + return write_screenshot_request( + allow_dispatch=False, + disabled_reason="ProReq-light keeps frontend image generation as request-only evidence to avoid metered image API dispatch.", + ) + + +def command_pro_request(_args: argparse.Namespace) -> int: + current, _latest = artifact_dirs() + schema = output_schema() + schema_rel = write_run_artifact( + "pro_output_schema.json", + { + "schema_version": "cento.hard_proreq.schema_manifest.v1", + "schema_name": "cento_hard_proreq_backend_plan", + "api_surface": "OpenAI Responses API text.format json_schema strict true", + "codex_exec_flag": "--output-schema", + "schema": schema, + }, + ) + context = read_json(current / "mini_cento_context.json") or read_json(PIPELINE_ROOT / "execution" / "hard-proreq" / "latest" / "mini_cento_context.json") + screenshot = read_json(current / "ui_screenshot_request.json") or read_json(PIPELINE_ROOT / "execution" / "hard-proreq" / "latest" / "ui_screenshot_request.json") + request = { + "model": os.environ.get("CENTO_OPENAI_PRO_MODEL", "gpt-5.4-pro"), + "background": True, + "instructions": ( + "You are GPT Pro acting only as a backend planning advisor for Cento. " + "Use the operator input, mini Cento context, and questionnaire answers to produce ideal backend work separation, " + f"integration sequencing, and validation gates. Produce exactly {STORY_COUNT} backend story workstreams. " + "Treat frontend screenshot work as muted and separate. " + f"Integration must be deterministic first; if model review is needed, the model ceiling is {INTEGRATION_MODEL_CEILING}. " + "Return only compact JSON matching the strict schema; keep each list to the essential items needed for handoff." + ), + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": json.dumps( + { + "operator_prompt": operator_prompt(), + "mini_cento_context": context, + "muted_frontend_screenshot_request": screenshot, + "required_output": f"exactly {STORY_COUNT} backend story workstreams, integration plan, validation plan, parallelization notes, and Codex exec prompts", + "budget_policy": {"target_usd": BUDGET_TARGET_USD, "max_usd": BUDGET_MAX_USD}, + "integration_model_policy": {"deterministic_first": True, "model_ceiling": INTEGRATION_MODEL_CEILING, "only_if_needed": True}, + }, + indent=2, + sort_keys=True, + ), + } + ], + } + ], + "text": { + "format": { + "type": "json_schema", + "name": "cento_hard_proreq_backend_plan", + "description": "Backend-only hard proreq plan for Cento.", + "strict": True, + "schema": schema, + } + }, + "max_output_tokens": int(os.environ.get("CENTO_HARD_PROREQ_MAX_OUTPUT_TOKENS", "16000")), + "metadata": {"schema_manifest": schema_rel, "run_id": run_id()}, + } + write_run_artifact("pro_backend_request.json", {"schema_version": "cento.hard_proreq.pro_request.v1", "request": request}) + return 0 + + +def extract_output_text(response: dict[str, Any]) -> str: + direct = response.get("output_text") + if isinstance(direct, str): + return direct + chunks: list[str] = [] + for item in response.get("output") or []: + if not isinstance(item, dict): + continue + for content in item.get("content") or []: + if isinstance(content, dict) and isinstance(content.get("text"), str): + chunks.append(content["text"]) + return "".join(chunks).strip() + + +def extract_json_object(text: str) -> dict[str, Any]: + text = str(text or "").strip() + if not text: + return {} + candidates = [text] + first = text.find("{") + last = text.rfind("}") + if first >= 0 and last > first: + candidates.append(text[first : last + 1]) + for candidate in candidates: + try: + payload = json.loads(candidate) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + return payload + return {} + + +def fallback_workstreams(prompt: str) -> list[dict[str, Any]]: + specs = [ + ( + "run-input-contract", + "Run Input Contract", + "Accept operator thoughts plus optional screenshot context through the Run Pipeline API contract.", + ["scripts/agent_work_app.py"], + ["python3 -m py_compile scripts/agent_work_app.py"], + ), + ( + "hard-proreq-story-splitter", + "Hard Proreq Story Splitter", + "Materialize exactly ten backend story manifests from the proreq plan.", + ["scripts/dev_pipeline_hard_proreq.py"], + ["python3 -m py_compile scripts/dev_pipeline_hard_proreq.py"], + ), + ( + "parallel-workset-runner", + "Parallel Workset Runner", + "Run independent patch workers through exclusive write paths and serialized integration receipts.", + ["scripts/cento_workset.py"], + ["python3 -m py_compile scripts/cento_workset.py"], + ), + ( + "structured-api-worker", + "Structured API Worker", + "Keep patch workers structured, repo-mutating only through materialization and receipts.", + ["scripts/cento_openai_worker.py"], + ["python3 -m py_compile scripts/cento_openai_worker.py"], + ), + ( + "budget-model-guardrails", + "Budget And Model Guardrails", + "Apply the $10 target / $20 cap and keep integration fallback at gpt-4.1-mini or smaller.", + [".cento/api_workers.yaml"], + ["python3 - <<'PY'\nimport yaml\nprint(yaml.safe_load(open('.cento/api_workers.yaml'))['openai']['budget_usd_max'])\nPY"], + ), + ( + "run-pipeline-ui-behavior", + "Run Pipeline UI Behavior", + "Expose operator thoughts and optional screenshot path in the API-backed Run Pipeline modal.", + ["templates/agent-work-app/app.js"], + ["node --check templates/agent-work-app/app.js"], + ), + ( + "run-pipeline-modal", + "Run Pipeline Modal", + "Keep the first screen clear for entering thoughts, optional screenshot context, and starting the run.", + ["templates/agent-work-app/index.html"], + ["python3 - <<'PY'\nfrom pathlib import Path\nassert 'runPipelineScreenshot' in Path('templates/agent-work-app/index.html').read_text()\nPY"], + ), + ( + "execution-flow-styling", + "Execution Flow Styling", + "Keep ten-story and parallel workset UI panels readable without clipped text or overlapping cards.", + ["templates/agent-work-app/styles.css"], + ["python3 - <<'PY'\nfrom pathlib import Path\nassert Path('templates/agent-work-app/styles.css').exists()\nPY"], + ), + ( + "delivery-tests", + "Delivery Tests", + "Cover optional screenshot payloads, ten story manifests, and model/budget guardrails.", + ["tests/test_dev_pipeline_delivery.py"], + ["python3 -m pytest tests/test_dev_pipeline_delivery.py"], + ), + ( + "run-contract-docs", + "Run Contract Docs", + "Document the UI-to-ten-stories-to-parallel-patches integration contract.", + ["docs/dev-pipeline-run-contracts.md"], + ["python3 - <<'PY'\nfrom pathlib import Path\nassert 'ten story' in Path('docs/dev-pipeline-run-contracts.md').read_text().lower()\nPY"], + ), + ] + streams: list[dict[str, Any]] = [] + for index, (stream_id, title, intent, owned_paths, commands) in enumerate(specs[:STORY_COUNT], start=1): + streams.append( + { + "id": stream_id, + "title": title, + "intent": f"{intent} Operator request: {prompt[:360]}", + "owned_paths": owned_paths, + "read_paths": ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "tests/**", "docs/**", "data/tools.json"], + "depends_on": [] if index <= 5 else ["run-input-contract", "hard-proreq-story-splitter"], + "validation_commands": commands, + "handoff_artifacts": [f"stories/{stream_id}.json", "parallel_patch_workset.json"], + } + ) + return streams + + +def normalize_workstream(item: dict[str, Any], index: int, prompt: str) -> dict[str, Any]: + fallback = fallback_workstreams(prompt)[(index - 1) % STORY_COUNT] + stream_id = slugify(str(item.get("id") or item.get("title") or fallback["id"]), fallback["id"]) + return { + "id": stream_id, + "title": str(item.get("title") or fallback["title"]), + "intent": str(item.get("intent") or item.get("description") or fallback["intent"]), + "owned_paths": [str(value) for value in item.get("owned_paths", fallback["owned_paths"]) if isinstance(value, str) and value.strip()] or fallback["owned_paths"], + "read_paths": [str(value) for value in item.get("read_paths", fallback["read_paths"]) if isinstance(value, str) and value.strip()] or fallback["read_paths"], + "depends_on": [str(value) for value in item.get("depends_on", []) if isinstance(value, str) and value.strip()], + "validation_commands": [str(value) for value in item.get("validation_commands", fallback["validation_commands"]) if isinstance(value, str) and value.strip()] or fallback["validation_commands"], + "handoff_artifacts": [str(value) for value in item.get("handoff_artifacts", fallback["handoff_artifacts"]) if isinstance(value, str) and value.strip()] or fallback["handoff_artifacts"], + } + + +def normalize_backend_plan(plan: dict[str, Any], prompt: str, risks: list[str] | None = None) -> dict[str, Any]: + raw_streams = plan.get("backend_workstreams") if isinstance(plan.get("backend_workstreams"), list) else [] + streams = [ + normalize_workstream(item, index, prompt) + for index, item in enumerate(raw_streams, start=1) + if isinstance(item, dict) + ][:STORY_COUNT] + fallback_streams = fallback_workstreams(prompt) + existing_ids = {stream["id"] for stream in streams} + for item in fallback_streams: + if len(streams) >= STORY_COUNT: + break + if item["id"] in existing_ids: + continue + streams.append(item) + existing_ids.add(item["id"]) + integration_plan = [str(item) for item in plan.get("integration_plan", []) if isinstance(item, str) and item.strip()] + validation_plan = [str(item) for item in plan.get("validation_plan", []) if isinstance(item, str) and item.strip()] + parallel_notes = [str(item) for item in plan.get("parallelization_notes", []) if isinstance(item, str) and item.strip()] + exec_prompts = [item for item in plan.get("codex_exec_prompts", []) if isinstance(item, dict)] + if not integration_plan: + integration_plan = [ + "Generate ten story manifests and a cento.workset.v1 parallel patch handoff before dispatch.", + "Run patch workers in parallel only where write_paths are exclusive.", + "Apply patch bundles through one manifest-driven sequential integrator with rollback receipts.", + f"Use deterministic integration first; if model review is required, cap it at {INTEGRATION_MODEL_CEILING}.", + ] + if not validation_plan: + validation_plan = [ + "Validate every story manifest as JSON.", + "Run workset check before dispatch.", + "Run py_compile, node --check, focused pytest, and UI screenshot verification.", + ] + if not parallel_notes: + parallel_notes = [ + f"{len(streams)} story lanes are ready for bounded parallel patch generation.", + "No worker may share write_paths with another worker.", + "Integration stays serialized and receipt-backed.", + ] + if not exec_prompts: + exec_prompts = [ + { + "id": stream["id"], + "prompt": f"Implement story {stream['title']} using only owned_paths={stream['owned_paths']}. Preserve unrelated dirty work.", + "output_schema": "patch_proposal.v1", + } + for stream in streams + ] + return { + "schema_version": "cento.hard_proreq_backend_plan.v1", + "summary": str(plan.get("summary") or f"Ten-story manifest-driven backend plan for: {prompt[:240]}"), + "backend_workstreams": streams, + "integration_plan": integration_plan, + "validation_plan": validation_plan, + "parallelization_notes": parallel_notes, + "codex_exec_prompts": exec_prompts[:STORY_COUNT], + "risks": [str(item) for item in (risks if risks is not None else plan.get("risks", [])) if isinstance(item, str) and item.strip()], + } + + +def command_pro_plan(_args: argparse.Namespace) -> int: + current, latest = artifact_dirs() + request_payload = read_json(current / "pro_backend_request.json") or read_json(latest / "pro_backend_request.json") + request = request_payload.get("request") if isinstance(request_payload.get("request"), dict) else {} + dispatch_status = "not_requested" + dispatch_error = "" + dispatch_skip_code = "" + if os.environ.get("CENTO_HARD_PROREQ_DISPATCH_PRO", "").lower() in {"1", "true", "yes"} and os.environ.get("OPENAI_API_KEY") and request: + budget_gate = metered_api_budget_gate() + if not bool(budget_gate.get("allowed")): + dispatch_status = "skipped" + dispatch_skip_code = "dashboard-budget-gate" + dispatch_error = str(budget_gate.get("reason") or "dashboard budget gate blocked Pro dispatch") + write_run_artifact("pro_backend_budget_gate.json", budget_gate) + append_api_spend( + lane="pro", + category="pro", + model=str(request.get("model") or ""), + status="skipped", + artifact="pro_backend_budget_gate.json", + note=dispatch_error, + cost_accuracy="budget-gated", + ) + else: + try: + request = {**request, "background": False} + body = json.dumps(request).encode("utf-8") + http = urllib.request.Request(RESPONSES_URL, data=body, method="POST", headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}", "Content-Type": "application/json"}) + timeout_seconds = int(os.environ.get("CENTO_HARD_PROREQ_PRO_TIMEOUT", "300")) + append_api_spend( + lane="pro", + category="pro", + model=str(request.get("model") or ""), + status="started", + artifact="pro_backend_request.json", + note=f"Responses API Pro call starting; timeout_seconds={timeout_seconds}", + ) + with urllib.request.urlopen(http, timeout=timeout_seconds) as response: + response_payload = json.loads(response.read().decode("utf-8")) + dispatch_status = str(response_payload.get("status") or "unknown") + write_run_artifact("pro_backend_response.json", {"schema_version": "cento.hard_proreq.pro_response.v1", "response": response_payload}) + append_api_spend( + lane="pro", + category="pro", + model=str(request.get("model") or ""), + status=dispatch_status, + usage=response_payload.get("usage") if isinstance(response_payload.get("usage"), dict) else {}, + response=response_payload, + artifact="pro_backend_response.json", + note="Responses API Pro call completed", + ) + output_text = extract_output_text(response_payload) + if output_text: + try: + plan = json.loads(output_text) + except json.JSONDecodeError: + plan = {} + if isinstance(plan, dict) and plan.get("schema_version") == "cento.hard_proreq_backend_plan.v1": + write_run_artifact("pro_backend_plan.json", normalize_backend_plan(plan, operator_prompt())) + return 0 + dispatch_error = f"GPT pro response did not include schema JSON; status={dispatch_status}" + except Exception as exc: + if is_timeout_exception(exc): + dispatch_status = "timeout" + timeout_seconds = int(os.environ.get("CENTO_HARD_PROREQ_PRO_TIMEOUT", "300")) + dispatch_error = f"Pro Responses API call timed out after {timeout_seconds}s: {type(exc).__name__}: {exc}" + timeout_record = { + "schema_version": "cento.hard_proreq.pro_timeout.v1", + "run_id": run_id(), + "status": dispatch_status, + "model": str(request.get("model") or ""), + "timeout_seconds": timeout_seconds, + "error": dispatch_error, + "request_present": bool(request), + } + write_run_artifact("pro_backend_timeout.json", timeout_record) + write_run_artifact("pro_backend_response.json", {**timeout_record, "schema_version": "cento.hard_proreq.pro_response.v1"}) + append_api_spend( + lane="pro", + category="pro", + model=str(request.get("model") or ""), + status="timeout", + artifact="pro_backend_timeout.json", + note=dispatch_error, + cost_accuracy="unknown-timeout", + ) + else: + dispatch_status = "failed" + dispatch_error = f"{type(exc).__name__}: {exc}" + write_run_artifact( + "pro_backend_error.json", + { + "schema_version": "cento.hard_proreq.pro_error.v1", + "run_id": run_id(), + "status": dispatch_status, + "error": dispatch_error, + }, + ) + append_api_spend( + lane="pro", + category="pro", + model=str(request.get("model") or ""), + status="failed", + artifact="pro_backend_error.json", + note=dispatch_error, + cost_accuracy="unknown-failed-call", + ) + + prompt = operator_prompt() + fallback_summary = "GPT pro request is schema-ready; backend work uses deterministic fallback until CENTO_HARD_PROREQ_DISPATCH_PRO=1 is enabled." + fallback_risks = ["Pro API dispatch is gated unless CENTO_HARD_PROREQ_DISPATCH_PRO=1 and OPENAI_API_KEY are configured."] + if dispatch_error: + fallback_summary = f"GPT pro dispatch was attempted but did not return schema JSON ({dispatch_error}); backend work uses deterministic fallback." + fallback_risks = [dispatch_error] + skip_code = "dispatch-disabled" + if not request: + skip_code = "missing-request" + elif not os.environ.get("OPENAI_API_KEY"): + skip_code = "missing-openai-api-key" + elif os.environ.get("CENTO_HARD_PROREQ_DISPATCH_PRO", "").lower() not in {"1", "true", "yes"}: + skip_code = "dispatch-disabled" + response_status = "timeout" if dispatch_status == "timeout" else ("skipped" if dispatch_skip_code else ("failed" if dispatch_error else "skipped")) + write_run_artifact( + "pro_backend_response.json", + { + "schema_version": "cento.hard_proreq.pro_response.v1", + "run_id": run_id(), + "status": response_status, + "dispatch_status": dispatch_status, + "skip_code": dispatch_skip_code or ("" if dispatch_error else skip_code), + "error": dispatch_error, + "model": str(request.get("model") or ""), + "request_present": bool(request), + }, + ) + write_run_artifact( + "pro_backend_error.json", + { + "schema_version": "cento.hard_proreq.pro_error.v1", + "run_id": run_id(), + "status": response_status, + "error": dispatch_error, + "reason": fallback_risks[0] if fallback_risks else "", + }, + ) + write_run_artifact( + "pro_backend_plan.json", + normalize_backend_plan( + { + "summary": fallback_summary, + "backend_workstreams": fallback_workstreams(prompt), + "risks": fallback_risks, + }, + prompt, + fallback_risks, + ), + ) + return 0 + + +def codex_proreq_light_prompt( + *, + request: dict[str, Any], + schema_payload: dict[str, Any], + context: dict[str, Any], + screenshot: dict[str, Any], + prompt: str, +) -> str: + schema = schema_payload.get("schema") if isinstance(schema_payload.get("schema"), dict) else output_schema() + return "\n".join( + [ + "You're chatGPT Pro model for this Cento proreq-light run.", + "", + "Act like the Hard ProReq ChatGPT Pro backend planning lane, but run inside Codex Exec.", + "Use deep planning judgment, keep frontend screenshot work separate, and produce only compact JSON matching the schema.", + "Do not write code, do not mutate the repository, and do not call external APIs from this planning step.", + "", + "Required JSON schema:", + json.dumps(schema, indent=2, sort_keys=True), + "", + "Operator request:", + prompt, + "", + "Mini Cento context:", + json.dumps(context, indent=2, sort_keys=True)[:18000], + "", + "Muted frontend screenshot request:", + json.dumps(screenshot, indent=2, sort_keys=True)[:10000], + "", + "Original Pro request shape to emulate:", + json.dumps(request, indent=2, sort_keys=True)[:18000], + "", + "Return exactly one JSON object with schema_version=cento.hard_proreq_backend_plan.v1.", + f"Return exactly {STORY_COUNT} backend_workstreams unless the schema forces a fallback.", + "Each workstream must have exclusive owned_paths, read_paths, depends_on, validation_commands, and handoff_artifacts.", + f"Use deterministic integration first; if model review is required later, cap it at {INTEGRATION_MODEL_CEILING}.", + ] + ).strip() + "\n" + + +def command_codex_pro_plan(_args: argparse.Namespace) -> int: + current, latest = artifact_dirs() + if not (current / "pro_backend_request.json").exists() and not (latest / "pro_backend_request.json").exists(): + command_pro_request(argparse.Namespace()) + request_payload = read_json(current / "pro_backend_request.json") or read_json(latest / "pro_backend_request.json") + request = request_payload.get("request") if isinstance(request_payload.get("request"), dict) else {} + schema_payload = read_json(current / "pro_output_schema.json") or read_json(latest / "pro_output_schema.json") + context = read_json(current / "mini_cento_context.json") or read_json(latest / "mini_cento_context.json") + screenshot = read_json(current / "ui_screenshot_request.json") or read_json(latest / "ui_screenshot_request.json") + raw_schema = schema_payload.get("schema") if isinstance(schema_payload.get("schema"), dict) else output_schema() + prompt_text = codex_proreq_light_prompt( + request=request, + schema_payload=schema_payload, + context=context, + screenshot=screenshot, + prompt=operator_prompt(), + ) + prompt_rel = write_run_text("proreq_light_codex_prompt.md", prompt_text) + schema_rel = write_run_artifact("proreq_light_output_schema.json", raw_schema) + schema_path = current / "proreq_light_output_schema.json" + codex_bin = os.environ.get("CENTO_PROREQ_LIGHT_CODEX_BIN", "").strip() or shutil.which("codex") or "codex" + command = [codex_bin, "exec", "--sandbox", "read-only", "--output-schema", str(schema_path), "-C", str(ROOT)] + command_rel = write_run_artifact( + "proreq_light_codex_command.json", + { + "schema_version": "cento.proreq_light.codex_command.v1", + "run_id": run_id(), + "status": "configured", + "prompt": prompt_rel, + "output_schema": schema_rel, + "command": command, + "stdin": prompt_rel, + "cost_policy": "no metered OpenAI API; uses Codex Exec route", + }, + ) + skip = env_bool("CENTO_PROREQ_LIGHT_SKIP_CODEX_EXEC") + timeout_seconds = int(os.environ.get("CENTO_PROREQ_LIGHT_CODEX_TIMEOUT", "900")) + response_record: dict[str, Any] = { + "schema_version": "cento.proreq_light.codex_response.v1", + "run_id": run_id(), + "backend": "codex-exec-proreq-light", + "prompt": prompt_rel, + "command": command_rel, + "status": "skipped" if skip else "started", + "cost_usd": 0.0, + "cost_accuracy": "no-metered-openai-api", + } + plan_payload: dict[str, Any] = {} + risks: list[str] = [] + if skip: + risks.append("Codex Exec was skipped by CENTO_PROREQ_LIGHT_SKIP_CODEX_EXEC; deterministic fallback plan was used.") + else: + try: + result = subprocess.run( + command, + cwd=ROOT, + input=prompt_text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout_seconds, + check=False, + ) + stdout_rel = write_run_text("proreq_light_codex_stdout.txt", result.stdout or "") + stderr_rel = write_run_text("proreq_light_codex_stderr.txt", result.stderr or "") + plan_payload = extract_json_object(result.stdout) + response_record.update( + { + "status": "completed" if result.returncode == 0 and plan_payload else "fallback", + "exit_code": result.returncode, + "stdout": stdout_rel, + "stderr": stderr_rel, + "parsed_json": bool(plan_payload), + } + ) + if result.returncode != 0: + risks.append(f"Codex Exec exited {result.returncode}; deterministic fallback plan was used.") + if not plan_payload: + risks.append("Codex Exec did not return parseable schema JSON; deterministic fallback plan was used.") + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout if isinstance(exc.stdout, str) else "" + stderr = exc.stderr if isinstance(exc.stderr, str) else "" + stdout_rel = write_run_text("proreq_light_codex_stdout.txt", stdout) + stderr_rel = write_run_text("proreq_light_codex_stderr.txt", stderr) + response_record.update({"status": "timeout", "stdout": stdout_rel, "stderr": stderr_rel, "timeout_seconds": timeout_seconds}) + risks.append(f"Codex Exec timed out after {timeout_seconds}s; deterministic fallback plan was used.") + except OSError as exc: + response_record.update({"status": "unavailable", "error": f"{type(exc).__name__}: {exc}"}) + risks.append(f"Codex Exec was unavailable ({type(exc).__name__}: {exc}); deterministic fallback plan was used.") + + if not plan_payload: + plan_payload = { + "schema_version": "cento.hard_proreq_backend_plan.v1", + "summary": f"ProReq-light Codex Exec fallback plan for: {operator_prompt()[:240]}", + "backend_workstreams": fallback_workstreams(operator_prompt()), + "risks": risks, + } + normalized = normalize_backend_plan(plan_payload, operator_prompt(), risks) + write_run_artifact("pro_backend_response.json", response_record) + write_run_artifact( + "pro_backend_error.json", + { + "schema_version": "cento.hard_proreq.pro_error.v1", + "run_id": run_id(), + "status": response_record.get("status"), + "error": "; ".join(risks), + "reason": "Codex Exec ProReq-light fallback" if risks else "", + }, + ) + write_run_artifact("pro_backend_plan.json", normalized) + write_run_artifact( + "proreq_light_codex_response.json", + { + **response_record, + "plan_status": "codex" if not risks and bool(plan_payload) else "fallback", + "risk_count": len(risks), + }, + ) + return 0 + + +def command_backend_work(_args: argparse.Namespace) -> int: + current, latest = artifact_dirs() + plan = normalize_backend_plan(read_json(current / "pro_backend_plan.json") or read_json(latest / "pro_backend_plan.json"), operator_prompt()) + schema_path = rel(latest / "pro_output_schema.json") + commands = [] + for prompt in plan.get("codex_exec_prompts", []) if isinstance(plan.get("codex_exec_prompts"), list) else []: + if not isinstance(prompt, dict): + continue + prompt_file = f"workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/codex_{prompt.get('id') or 'backend'}.md" + commands.append( + { + "id": str(prompt.get("id") or "backend-work"), + "prompt_file": prompt_file, + "command": f"codex exec --output-schema {schema_path} -C {ROOT.as_posix()} < {prompt_file}", + } + ) + stories: list[dict[str, Any]] = [] + tasks: list[dict[str, Any]] = [] + for index, stream in enumerate(plan.get("backend_workstreams", []) if isinstance(plan.get("backend_workstreams"), list) else [], start=1): + if not isinstance(stream, dict): + continue + story_id = slugify(str(stream.get("id") or f"story-{index}"), f"story-{index}") + run_dir = f"workspace/runs/agent-work/0/hard-proreq/{run_id()}/{story_id}" + validation_manifest = f"{run_dir}/validation.json" + deliverables_manifest = f"{run_dir}/deliverables.json" + deliverables_hub = f"{run_dir}/start-here.html" + owned_paths = [str(value) for value in stream.get("owned_paths", []) if isinstance(value, str) and value.strip()] + expected_outputs = [ + { + "path": path, + "description": f"Patch output owned by {story_id}", + "owner": "hard-proreq", + "required": True, + } + for path in owned_paths + ] or [ + { + "path": f"workspace/runs/hard-proreq/outputs/{story_id}.json", + "description": f"Patch output owned by {story_id}", + "owner": "hard-proreq", + "required": True, + } + ] + story = { + "schema_version": "1.0", + "issue": { + "id": 0, + "title": str(stream.get("title") or story_id), + "package": f"hard-proreq/{run_id()}", + }, + "lane": { + "owner": "hard-proreq", + "node": "linux", + "agent": "codex-exec", + "role": "builder", + }, + "paths": { + "run_dir": run_dir, + }, + "scope": { + "goal": str(stream.get("intent") or stream.get("title") or story_id), + "acceptance": [ + "Only declared owned paths are changed.", + "Patch proposal is returned as structured patch_proposal.v1 JSON.", + "Integration is accepted through a manifest-driven sequential receipt.", + ], + }, + "expected_outputs": expected_outputs, + "validation": { + "manifest": validation_manifest, + "mode": "no-model", + "no_model_eligible": True, + "risk": "medium", + "escalation_triggers": ["missing_manifest", "failed_deterministic_command", "ambiguity"], + "commands": [str(value) for value in stream.get("validation_commands", []) if isinstance(value, str) and value.strip()] + or [f"python3 -m json.tool {validation_manifest}"], + }, + "deliverables": { + "manifest": deliverables_manifest, + "hub": deliverables_hub, + }, + "review_gate": { + "required_sections": ["Delivered", "Validation", "Evidence", "Residual risk"], + "residual_risk_required": True, + }, + "metadata": { + "drafted_at": now_iso(), + "source": "hard-proreq-ten-story-split", + "integration_model_policy": { + "mode": "deterministic-first", + "fallback": "only-if-needed", + "model_ceiling": INTEGRATION_MODEL_CEILING, + }, + }, + } + story_rel = write_run_artifact_path(f"stories/{story_id}.json", story) + validation_rel = write_run_artifact_path( + f"stories/{story_id}.validation.json", + { + "schema_version": "cento.validation_manifest.v1", + "story": story_rel, + "commands": story["validation"]["commands"], + "expected_outputs": expected_outputs, + }, + ) + stories.append( + { + "id": story_id, + "title": story["issue"]["title"], + "story_manifest": story_rel, + "validation_manifest": validation_rel, + "owned_paths": owned_paths, + "depends_on": [str(value) for value in stream.get("depends_on", []) if isinstance(value, str) and value.strip()], + } + ) + tasks.append( + { + "id": story_id, + "worker_id": f"codex-story-worker-{index}", + "task": str(stream.get("title") or story_id)[:240], + "description": ( + f"Implement story {story['issue']['title']} from {story_rel}. " + "Use Codex Exec local patch delivery. Do not edit files outside write_paths." + ), + "write_paths": [item["path"] for item in expected_outputs], + "read_paths": [str(value) for value in stream.get("read_paths", []) if isinstance(value, str) and value.strip()], + "routes": [], + "depends_on": [str(value) for value in stream.get("depends_on", []) if isinstance(value, str) and value.strip()], + "runtime": "local-command", + "runtime_profile": "codex-fast", + "cost_usd_estimate": 0.0, + } + ) + story_index_rel = write_run_artifact( + "story_index.json", + { + "schema_version": "cento.hard_proreq.story_index.v1", + "run_id": run_id(), + "story_count": len(stories), + "stories": stories, + }, + ) + task_ids = {str(task.get("id") or "") for task in tasks} + for task in tasks: + task["depends_on"] = [dep for dep in task.get("depends_on", []) if dep in task_ids] + workset_rel = write_run_artifact( + "parallel_patch_workset.json", + { + "schema_version": "cento.workset.v1", + "id": f"hard-proreq-{slugify(run_id())}", + "mode": "standard", + "max_parallel": min(5, max(1, len(tasks))), + "read_paths": ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "tests/**", "docs/**", "data/tools.json"], + "execution_model": "parallel", + "integration": "sequential", + "integration_model_policy": { + "mode": "deterministic-first", + "fallback": "disabled-for-proreq-light", + "model_ceiling": "none", + "profile": "local-codex-only", + }, + "budget": { + "target_usd": 0.0, + "max_usd": 0.0, + }, + "policies": {"allow_creates": True}, + "tasks": tasks, + }, + ) + integration_policy_rel = write_run_artifact( + "manifest_integration_policy.json", + { + "schema_version": "cento.hard_proreq.integration_policy.v1", + "run_id": run_id(), + "integration": "sequential", + "apply": "automatic-clean-owned-paths", + "model_policy": { + "deterministic_first": True, + "fallback": "disabled-for-proreq-light", + "model_ceiling": "none", + "profile": "local-codex-only", + }, + "budget": {"target_usd": 0.0, "max_usd": 0.0}, + "notifications": "muted", + }, + ) + write_run_artifact( + "backend_work_manifest.json", + { + "schema_version": "cento.hard_proreq.backend_work_manifest.v1", + "run_id": run_id(), + "source_plan": rel(current / "pro_backend_plan.json"), + "story_count": len(stories), + "story_index": story_index_rel, + "story_manifests": [story["story_manifest"] for story in stories], + "parallel_patch_workset": workset_rel, + "integration_policy": integration_policy_rel, + "workstreams": plan.get("backend_workstreams", []), + "cento_native_commands": [ + "cento gather-context --no-remote", + "cento tools", + f"cento workset check {workset_rel} --allow-creates", + f"cento workset execute {workset_rel} --max-parallel 3 --runtime local-command --runtime-profile codex-fast --allow-creates --integrate sequential --apply --validation smoke", + "cento proreq-light deliver --max-parallel 3 --runtime-profile codex-fast --json", + "cento agent-work create --manifest --title ", + ], + "codex_exec": commands, + "taskstream_creation": "planned_after_story_manifest_review", + "notifications": "muted", + }, + ) + return 0 + + +def command_integration(_args: argparse.Namespace) -> int: + current, latest = artifact_dirs() + plan = normalize_backend_plan(read_json(current / "pro_backend_plan.json") or read_json(latest / "pro_backend_plan.json"), operator_prompt()) + backend = read_json(current / "backend_work_manifest.json") or read_json(latest / "backend_work_manifest.json") + write_run_artifact( + "integration_plan.json", + { + "schema_version": "cento.hard_proreq.integration_plan.v1", + "run_id": run_id(), + "steps": plan.get("integration_plan", []), + "story_count": len(plan.get("backend_workstreams", []) if isinstance(plan.get("backend_workstreams"), list) else []), + "parallel_patch_workset": str(backend.get("parallel_patch_workset") or "parallel_patch_workset.json"), + "policy": "Sequential manifest-driven integration after each backend story returns evidence. No frontend screenshot artifact can own backend mutation.", + "apply": "automatic-clean-owned-paths", + "model_policy": { + "deterministic_first": True, + "fallback": "disabled-for-proreq-light", + "model_ceiling": "none", + "profile": "local-codex-only", + }, + "notifications": "muted", + }, + ) + return 0 + + +def command_validation(_args: argparse.Namespace) -> int: + current, latest = artifact_dirs() + plan = normalize_backend_plan(read_json(current / "pro_backend_plan.json") or read_json(latest / "pro_backend_plan.json"), operator_prompt()) + backend = read_json(current / "backend_work_manifest.json") or read_json(latest / "backend_work_manifest.json") + write_run_artifact( + "validation_plan.json", + { + "schema_version": "cento.hard_proreq.validation_plan.v1", + "run_id": run_id(), + "story_count": len(plan.get("backend_workstreams", []) if isinstance(plan.get("backend_workstreams"), list) else []), + "commands": plan.get("validation_plan", []), + "required_local_checks": [ + "python3 -m py_compile scripts/agent_work_app.py scripts/dev_pipeline_hard_proreq.py scripts/cento_openai_worker.py", + "node --check templates/agent-work-app/app.js", + "python3 -m json.tool workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/story_index.json", + f"cento workset check {backend.get('parallel_patch_workset') or 'workspace/runs/dev-pipeline-studio/docs-pages/latest/execution/hard-proreq/latest/parallel_patch_workset.json'} --allow-creates", + "cento proreq-light deliver --max-parallel 3 --runtime-profile codex-fast --json", + ], + "visual_check": "Firefox screenshot must show Hard Proreq Project selected, optional screenshot input visible, ten-story backend handoff artifacts, and frontend lane muted.", + "notifications": "muted", + }, + ) + return 0 + + +def command_evidence(_args: argparse.Namespace) -> int: + current, latest = artifact_dirs() + names = [ + "operator_intake.json", + "mini_cento_context.json", + "ui_screenshot_request.json", + "existing_ui_reference.png", + "existing_ui_reference_square.png", + "image_generation_request.json", + "image_generation_response.json", + "generated_integrator_screenshot.png", + "pro_output_schema.json", + "pro_backend_request.json", + "pro_backend_response.json", + "pro_backend_error.json", + "pro_backend_plan.json", + "story_index.json", + "parallel_patch_workset.json", + "manifest_integration_policy.json", + "backend_work_manifest.json", + "integration_plan.json", + "validation_plan.json", + ] + artifacts = [] + for name in names: + path = current / name + if not path.exists(): + path = latest / name + artifacts.append({"name": name, "path": rel(path), "exists": path.exists(), "size_bytes": path.stat().st_size if path.exists() else 0}) + write_run_artifact( + "hard_proreq_evidence.json", + { + "schema_version": "cento.hard_proreq.evidence.v1", + "run_id": run_id(), + "status": "completed", + "artifacts": artifacts, + "budget": {"target_usd": BUDGET_TARGET_USD, "max_usd": BUDGET_MAX_USD}, + "notification_policy": "muted; do not send SMS or phone notifications", + }, + ) + return 0 + + +def command_all(args: argparse.Namespace) -> int: + for func in [command_intake, command_context, command_screenshot, command_pro_request, command_pro_plan, command_backend_work, command_integration, command_validation, command_evidence]: + code = func(args) + if code: + return code + return 0 + + +def command_light_all(args: argparse.Namespace) -> int: + for func in [command_intake, command_context, command_light_screenshot, command_pro_request, command_codex_pro_plan, command_backend_work, command_integration, command_validation, command_evidence]: + code = func(args) + if code: + return code + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate hard-proreq pipeline artifacts.") + sub = parser.add_subparsers(dest="command", required=True) + commands = { + "intake": command_intake, + "context": command_context, + "screenshot": command_screenshot, + "light-screenshot": command_light_screenshot, + "pro-request": command_pro_request, + "pro-plan": command_pro_plan, + "codex-pro-plan": command_codex_pro_plan, + "backend-work": command_backend_work, + "integration-plan": command_integration, + "validation-plan": command_validation, + "evidence": command_evidence, + "all": command_all, + "light-all": command_light_all, + } + for name, func in commands.items(): + item = sub.add_parser(name) + item.set_defaults(func=func) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev_pipeline_multipipeline.py b/scripts/dev_pipeline_multipipeline.py new file mode 100755 index 0000000..a1da09d --- /dev/null +++ b/scripts/dev_pipeline_multipipeline.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Generate sequential multipipeline ProReq artifacts for Dev Pipeline Studio runs.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +PIPELINE_ROOT = Path(os.environ.get("CENTO_DEV_PIPELINE_STUDIO_ROOT", ROOT / "workspace" / "runs" / "dev-pipeline-studio" / "docs-pages" / "latest")) +HARD_PROREQ_PROJECT_ID = "hard-proreq-project" +HARD_PROREQ_TEMPLATE_ID = "hard-proreq-task" +PASS_FOCUS = [ + ( + "scope", + "Scope and guardrails", + "Clarify the operator-defined multipipeline objective, default compute policy, side-effect boundaries, and success evidence.", + ), + ( + "architecture", + "Pipeline architecture", + "Turn pass 1 guidance into route contracts, data artifacts, UI states, and deterministic fallback behavior.", + ), + ( + "integration", + "Integration and migration", + "Turn pass 2 guidance into implementation worksets, integration order, rollback points, and operator-facing handoff contracts.", + ), + ( + "validation", + "Validation and demo", + "Turn pass 3 guidance into validators, demo task, residual risks, and the next high-confidence execution prompt.", + ), +] + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + return payload if isinstance(payload, dict) else {} + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text.rstrip() + "\n", encoding="utf-8") + + +def run_payload() -> dict[str, Any]: + return read_json(PIPELINE_ROOT / "execution" / "execution_run.json") + + +def run_id() -> str: + return str(run_payload().get("run_id") or "manual-multipipeline") + + +def artifact_dirs() -> tuple[Path, Path]: + current = PIPELINE_ROOT / "execution" / "multipipeline" / run_id() + latest = PIPELINE_ROOT / "execution" / "multipipeline" / "latest" + current.mkdir(parents=True, exist_ok=True) + latest.mkdir(parents=True, exist_ok=True) + return current, latest + + +def write_artifact(name: str, payload: dict[str, Any]) -> str: + current, latest = artifact_dirs() + payload = {**payload, "written_at": now_iso()} + current_path = current / name + latest_path = latest / name + write_json(current_path, payload) + write_json(latest_path, payload) + return rel(current_path) + + +def write_text_artifact(name: str, text: str) -> str: + current, latest = artifact_dirs() + current_path = current / name + latest_path = latest / name + write_text(current_path, text) + write_text(latest_path, text) + return rel(current_path) + + +def slugify(value: str, fallback: str = "item") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug[:64] or fallback + + +def pipeline_inputs() -> list[dict[str, Any]]: + return [item for item in run_payload().get("inputs", []) if isinstance(item, dict)] + + +def prompt_text() -> str: + payload = run_payload() + prompt = str(payload.get("prompt") or "").strip() + if prompt: + return prompt + for item in pipeline_inputs(): + if str(item.get("source") or "") == "user" and str(item.get("answer") or "").strip(): + return str(item.get("answer") or "").strip() + return "Run a four-pass ProReq chain from the operator objective." + + +def config_text() -> str: + for item in pipeline_inputs(): + if str(item.get("id") or "") == "multipipeline-schedule-config": + return str(item.get("answer") or "").strip() + return "" + + +def parse_config() -> dict[str, str]: + defaults = { + "passes": "4", + "child_pipeline": HARD_PROREQ_TEMPLATE_ID, + "execution_mode": "request-artifacts", + "ui_screenshot": "request-artifact", + "pro_call": "request-artifact", + "handoff_policy": "previous-guidance-required", + } + for line in config_text().splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + key = re.sub(r"[^a-z0-9_]+", "_", key.strip().lower()).strip("_") + value = value.strip() + if key and value: + defaults[key] = value + defaults["passes"] = "4" + defaults["child_pipeline"] = HARD_PROREQ_TEMPLATE_ID + return defaults + + +def optional_image_refs() -> list[str]: + refs: list[str] = [] + image_suffixes = {".png", ".jpg", ".jpeg", ".gif", ".webp"} + for item in pipeline_inputs(): + if str(item.get("id") or "") == "ui-screenshot-request": + refs.extend( + str(value) + for value in item.get("image_refs", []) + if isinstance(value, str) and value.strip() and Path(value).suffix.lower() in image_suffixes + ) + return list(dict.fromkeys(refs)) + + +def previous_guidance(pass_index: int) -> dict[str, Any]: + if pass_index <= 1: + return {} + current, _latest = artifact_dirs() + return read_json(current / f"pass_{pass_index - 1:02d}_guidance.json") + + +def hard_proreq_payload(pass_index: int, pass_title: str, focus: str, carry_forward: str) -> dict[str, Any]: + operator_prompt = ( + f"Sequential ProReq chain pass {pass_index}/4: {pass_title}.\n\n" + f"Original operator objective:\n{prompt_text()}\n\n" + f"Focus for this pass:\n{focus}\n\n" + f"Carry forward from previous pass:\n{carry_forward or 'No previous pass; establish guardrails and the first next-step request.'}\n\n" + "Return integration manifests, validation manifests, UI guidance, cost-aware AI usage guidance, and a concrete next-step request for the following pass." + ) + return { + "schema_version": "cento.pipeline_run_request.v1", + "project_id": HARD_PROREQ_PROJECT_ID, + "template_id": HARD_PROREQ_TEMPLATE_ID, + "inputs": [ + {"id": "operator-thoughts", "kind": "questionnaire", "source": "user", "answer": operator_prompt}, + {"id": "generated-cento-context", "kind": "path", "source": "auto"}, + {"id": "ui-screenshot-request", "kind": "image", "source": "auto"}, + {"id": "pro-backend-schema", "kind": "details", "source": "auto"}, + {"id": "backend-work-handoff", "kind": "evidence", "source": "auto"}, + ], + } + + +def command_intake(_args: argparse.Namespace) -> int: + write_artifact( + "operator_intake.json", + { + "schema_version": "cento.multipipeline.operator_intake.v1", + "run_id": run_id(), + "objective": prompt_text(), + "config": parse_config(), + "input_ids": [str(item.get("id") or "") for item in pipeline_inputs()], + "compute_policy": "lowest-compute request artifacts by default; no live Pro, image, or worker dispatch unless explicitly enabled", + }, + ) + return 0 + + +def command_schedule(_args: argparse.Namespace) -> int: + config = parse_config() + passes = [] + for index, (pass_id, title, focus) in enumerate(PASS_FOCUS, start=1): + passes.append( + { + "id": f"pass-{index:02d}-{pass_id}", + "sequence": index, + "title": title, + "child_project_id": HARD_PROREQ_PROJECT_ID, + "child_template_id": HARD_PROREQ_TEMPLATE_ID, + "execution_mode": config["execution_mode"], + "depends_on": [] if index == 1 else [f"pass-{index - 1:02d}-{PASS_FOCUS[index - 2][0]}"], + "input_artifact": f"pass_{index:02d}_proreq_request.json", + "guidance_artifact": f"pass_{index:02d}_guidance.json", + "focus": focus, + } + ) + write_artifact( + "multipipeline_schedule.json", + { + "schema_version": "cento.multipipeline.schedule.v1", + "run_id": run_id(), + "strategy": "sequential-proreq-request-chain", + "passes": passes, + "handoff_policy": config["handoff_policy"], + "live_dispatch": False, + }, + ) + return 0 + + +def write_pass(pass_index: int) -> int: + pass_id, title, focus = PASS_FOCUS[pass_index - 1] + previous = previous_guidance(pass_index) + carry_forward = str(previous.get("next_step_request") or previous.get("summary") or "") + payload = hard_proreq_payload(pass_index, title, focus, carry_forward) + request_artifact = write_artifact( + f"pass_{pass_index:02d}_proreq_request.json", + { + "schema_version": "cento.multipipeline.proreq_pass_request.v1", + "run_id": run_id(), + "pass_id": f"pass-{pass_index:02d}-{pass_id}", + "sequence": pass_index, + "title": title, + "focus": focus, + "depends_on_guidance": f"pass_{pass_index - 1:02d}_guidance.json" if pass_index > 1 else "", + "dispatch": "request-artifact", + "child_pipeline_payload": payload, + }, + ) + next_request = ( + "Promote the validation/demo guidance into a scoped implementation run." + if pass_index == 4 + else f"Use {title.lower()} guidance to drive pass {pass_index + 1}: {PASS_FOCUS[pass_index][1]}." + ) + guidance = { + "schema_version": "cento.multipipeline.pass_guidance.v1", + "run_id": run_id(), + "pass_id": f"pass-{pass_index:02d}-{pass_id}", + "sequence": pass_index, + "status": "completed", + "summary": f"{title} request artifact is ready for the next sequential ProReq pass.", + "request_artifact": request_artifact, + "carry_forward": [ + focus, + "Keep Pro/image/worker dispatch request-only unless explicitly enabled.", + "Preserve deterministic integration and validation artifacts before asking another model.", + ], + "integration_guidance": [ + "Treat each child ProReq output as immutable evidence for the next pass.", + "Do not merge or dispatch implementation work until the final pass evidence is accepted.", + ], + "validation_guidance": [ + "Check that previous guidance is cited by the next request.", + "Block the chain if a pass omits integration, validation, UI, Pro, or next-step guidance.", + ], + "next_step_request": next_request, + } + write_artifact(f"pass_{pass_index:02d}_guidance.json", guidance) + return 0 + + +def command_ui_screenshot_request(_args: argparse.Namespace) -> int: + write_artifact( + "ui_screenshot_request.json", + { + "schema_version": "cento.multipipeline.ui_screenshot_request.v1", + "run_id": run_id(), + "status": "request-ready", + "mode": "request-artifact", + "reference_images": optional_image_refs(), + "prompt": ( + "Create a minimal Cento Dev Pipeline Studio screenshot for a four-pass sequential multipipeline execution. " + "Show a compact horizontal pass chain, current pass progress, previous-guidance handoff, UI screenshot request, " + "ChatGPT Pro request, deterministic validation, and evidence handoff. Keep the design sparse, stable, dark, " + "teal/orange accented, and avoid large artifact walls or jumpy layout." + ), + }, + ) + return 0 + + +def command_pro_request(_args: argparse.Namespace) -> int: + write_artifact( + "chatgpt_pro_request.json", + { + "schema_version": "cento.multipipeline.chatgpt_pro_request.v1", + "run_id": run_id(), + "status": "request-ready", + "mode": "request-artifact", + "model_role": "ChatGPT Pro planning and manifest synthesis", + "request": { + "objective": prompt_text(), + "required_output": [ + "four-pass integration manifests", + "four-pass validation manifests", + "UI screenshot guidance", + "cost-aware AI usage guidance", + "next implementation/demo task", + "residual risks and blockers", + ], + "context_artifacts": [ + "multipipeline_schedule.json", + "pass_01_guidance.json", + "pass_02_guidance.json", + "pass_03_guidance.json", + "pass_04_guidance.json", + "ui_screenshot_request.json", + ], + "constraints": [ + "Default to request artifacts and deterministic validation.", + "Only propose live Pro/image/API dispatch behind explicit enablement and budget caps.", + "Keep child pipeline requests compatible with cento.pipeline_run_request.v1.", + ], + }, + }, + ) + return 0 + + +def command_evidence(_args: argparse.Namespace) -> int: + current, _latest = artifact_dirs() + guidance = [read_json(current / f"pass_{index:02d}_guidance.json") for index in range(1, 5)] + artifacts = [ + "operator_intake.json", + "multipipeline_schedule.json", + *[f"pass_{index:02d}_proreq_request.json" for index in range(1, 5)], + *[f"pass_{index:02d}_guidance.json" for index in range(1, 5)], + "ui_screenshot_request.json", + "chatgpt_pro_request.json", + "chain_roadmap.md", + "multipipeline_evidence.json", + ] + roadmap = [ + "# Multipipeline ProReq Chain Roadmap", + "", + f"Run: `{run_id()}`", + "", + "This capability schedules four sequential ProReq request passes. Each pass consumes the previous pass guidance and prepares the next request without live Pro, image, or worker dispatch by default.", + "", + "## Passes", + "", + ] + for item in guidance: + roadmap.append(f"- {item.get('pass_id', 'pass')}: {item.get('summary', 'guidance ready')} Next: {item.get('next_step_request', '')}") + roadmap.extend( + [ + "", + "## Handoff", + "", + "- Use `chatgpt_pro_request.json` when live Pro planning is explicitly desired.", + "- Use `ui_screenshot_request.json` when UI image guidance is explicitly desired.", + "- Use `multipipeline_evidence.json` as the validation and demo entry point.", + ] + ) + write_text_artifact("chain_roadmap.md", "\n".join(roadmap)) + write_artifact( + "multipipeline_evidence.json", + { + "schema_version": "cento.multipipeline.evidence.v1", + "run_id": run_id(), + "status": "completed", + "pass_count": 4, + "completed_passes": [item.get("pass_id") for item in guidance if item.get("status") == "completed"], + "artifacts": artifacts, + "validation": { + "sequential_handoff": all(read_json(current / f"pass_{index:02d}_proreq_request.json") for index in range(1, 5)), + "ui_screenshot_request": (current / "ui_screenshot_request.json").exists(), + "chatgpt_pro_request": (current / "chatgpt_pro_request.json").exists(), + "roadmap": (current / "chain_roadmap.md").exists(), + }, + "residual_risks": [ + "Live Pro/image execution remains request-only until credentials, budget, and explicit operator approval are present.", + "The generated child ProReq requests are schedule artifacts; a later dispatcher must execute them if live child runs are desired.", + ], + }, + ) + return 0 + + +def command_all(args: argparse.Namespace) -> int: + for func in [command_intake, command_schedule]: + code = func(args) + if code: + return code + for index in range(1, 5): + code = write_pass(index) + if code: + return code + for func in [command_ui_screenshot_request, command_pro_request, command_evidence]: + code = func(args) + if code: + return code + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate sequential multipipeline ProReq artifacts.") + sub = parser.add_subparsers(dest="command", required=True) + commands = { + "intake": command_intake, + "schedule": command_schedule, + "ui-screenshot-request": command_ui_screenshot_request, + "pro-request": command_pro_request, + "evidence": command_evidence, + "all": command_all, + } + for name, func in commands.items(): + item = sub.add_parser(name) + item.set_defaults(func=func) + for index in range(1, 5): + item = sub.add_parser(f"pass-{index}") + item.set_defaults(func=lambda args, pass_index=index: write_pass(pass_index)) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docs_module_e2e.py b/scripts/docs_module_e2e.py index 0a9f10c..3b14c50 100755 --- a/scripts/docs_module_e2e.py +++ b/scripts/docs_module_e2e.py @@ -111,7 +111,7 @@ def write_section_stories(run_dir: Path, section_crops: dict[str, str]) -> Path: "id": "DOCS-MOD-001", "section": "topbar", "title": "Global documentation topbar", - "acceptance": "Brand, product navigation, Docs active state, review queue link, and New issue action match the screenshot.", + "acceptance": "Brand, product navigation, Docs active state, review queue link, and Run pipeline action match the screenshot.", }, { "id": "DOCS-MOD-002", diff --git a/scripts/factory.py b/scripts/factory.py index 8c7c0ce..7539c36 100644 --- a/scripts/factory.py +++ b/scripts/factory.py @@ -786,11 +786,18 @@ def command_integrate(args: argparse.Namespace) -> int: def command_validate_integrated(args: argparse.Namespace) -> int: run_dir = factory_dispatch_core.resolve_run_dir(args.run_dir) - payload = factory_integrator_core.validate_integrated(run_dir) + payload = factory_integrator_core.validate_integrated(run_dir, auto_merge=args.auto_merge) print(json.dumps(payload, indent=2, sort_keys=True) if args.json else rel(run_dir / "integration" / "integrated-validation.json")) return 0 if payload["decision"] == "approve" else 1 +def command_validate_fanout(args: argparse.Namespace) -> int: + run_dir = factory_dispatch_core.resolve_run_dir(args.run_dir) + payload = factory_integrator_core.validate_fanout(run_dir, max_parallel=args.max_parallel) + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else rel(run_dir / "integration" / "validation-fanout.json")) + return 0 if payload["status"] == "passed" else 1 + + def command_release_candidate(args: argparse.Namespace) -> int: run_dir = factory_dispatch_core.resolve_run_dir(args.run_dir) payload = factory_integrator_core.render_release_candidate(run_dir) @@ -798,6 +805,22 @@ def command_release_candidate(args: argparse.Namespace) -> int: return 0 +def command_merge(args: argparse.Namespace) -> int: + run_dir = factory_dispatch_core.resolve_run_dir(args.run_dir) + if not args.auto_merge_main: + print("cento factory merge requires --auto-merge-main", file=sys.stderr) + return 2 + payload = factory_integrator_core.auto_merge_main( + run_dir, + target_branch=args.target_branch, + remote=args.remote, + push=args.push, + dry_run=args.dry_run, + ) + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else rel(run_dir / "integration" / "merge-receipt.json")) + return 0 if payload.get("status") in {"planned", "merged_local", "pushed"} else 1 + + def command_sync_taskstream(args: argparse.Namespace) -> int: run_dir = factory_dispatch_core.resolve_run_dir(args.run_dir) payload = factory_integrator_core.taskstream_sync_preview(run_dir) @@ -979,14 +1002,31 @@ def build_parser() -> argparse.ArgumentParser: validate_integrated = sub.add_parser("validate-integrated", help="Validate integration-state and merge readiness.") validate_integrated.add_argument("run_dir") + validate_integrated.add_argument("--auto-merge", action="store_true", help="Evaluate readiness against the stricter auto-merge gate.") validate_integrated.add_argument("--json", action="store_true") validate_integrated.set_defaults(func=command_validate_integrated) + validate_fanout = sub.add_parser("validate-fanout", help="Validate candidate patches in parallel using cacheable deterministic gates.") + validate_fanout.add_argument("run_dir") + validate_fanout.add_argument("--max-parallel", type=int, default=32) + validate_fanout.add_argument("--json", action="store_true") + validate_fanout.set_defaults(func=command_validate_fanout) + release_candidate = sub.add_parser("release-candidate", help="Render integration release-candidate.md and summary HTML.") release_candidate.add_argument("run_dir") release_candidate.add_argument("--json", action="store_true") release_candidate.set_defaults(func=command_release_candidate) + merge = sub.add_parser("merge", help="Auto-merge a validated Safe Integrator branch into main, optionally pushing after post-merge validation.") + merge.add_argument("run_dir") + merge.add_argument("--auto-merge-main", action="store_true", help="Required acknowledgement for local main merge.") + merge.add_argument("--push", action="store_true", help="Push target branch to the configured remote after post-merge validation.") + merge.add_argument("--target-branch", default="main") + merge.add_argument("--remote", default="origin") + merge.add_argument("--dry-run", action="store_true") + merge.add_argument("--json", action="store_true") + merge.set_defaults(func=command_merge) + sync_taskstream = sub.add_parser("sync-taskstream", help="Preview Taskstream updates from integration results.") sync_taskstream.add_argument("run_dir") sync_taskstream.add_argument("--dry-run", action="store_true", default=True) diff --git a/scripts/factory_integrator_core.py b/scripts/factory_integrator_core.py index be4d8c4..239e3d3 100644 --- a/scripts/factory_integrator_core.py +++ b/scripts/factory_integrator_core.py @@ -4,10 +4,12 @@ import json import os +import hashlib import shutil import subprocess import sys import time +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -49,6 +51,16 @@ def append_jsonl(path: Path, row: dict[str, Any]) -> None: handle.write(json.dumps(row, sort_keys=True) + "\n") +def file_sha256(path: Path) -> str: + if not path.exists(): + return "" + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def run(command: list[str], *, cwd: Path, timeout: int = 120, input_text: str | None = None) -> dict[str, Any]: started = time.perf_counter() proc = subprocess.run( @@ -231,6 +243,128 @@ def create_apply_plan(run_dir: Path) -> dict[str, Any]: return payload +def validation_suite_for(changed_files: list[str]) -> list[str]: + suites = {"schema", "owned-path", "git-apply-check"} + if any(path.endswith(".py") for path in changed_files): + suites.add("python") + if any(path.endswith(".json") for path in changed_files): + suites.add("json") + if any(path.startswith("docs/") or path.endswith(".md") for path in changed_files): + suites.add("docs") + return sorted(suites) + + +def fanout_cache_key(run_dir: Path, record: dict[str, Any], patch_hash: str, suites: list[str]) -> str: + payload = { + "base_sha": dispatch.git_sha(short=False), + "run_id": run_dir.name, + "task_id": str(record.get("task_id") or ""), + "patch_hash": patch_hash, + "suites": suites, + } + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest() + + +def validate_fanout_candidate(run_dir: Path, record: dict[str, Any], *, cache_dir: Path) -> dict[str, Any]: + task_id = str(record.get("task_id") or "") + patch_bundle = read_json(patch_json_path(run_dir, task_id)) if patch_json_path(run_dir, task_id).exists() else {} + patch_file = patch_diff_path(run_dir, patch_bundle) if patch_bundle else Path(str(record.get("patch_file") or "")) + changed_files = [str(path) for path in record.get("changed_files") or patch_bundle.get("changed_files") or []] + patch_hash = file_sha256(patch_file) + suites = validation_suite_for(changed_files) + cache_key = fanout_cache_key(run_dir, record, patch_hash, suites) + cache_path = cache_dir / f"{cache_key}.json" + if cache_path.exists(): + cached = read_json(cache_path) + if cached: + return {**cached, "cache": "hit", "cache_key": cache_key} + + started = time.perf_counter() + checks: list[dict[str, Any]] = [] + + def add(name: str, passed: bool, detail: str = "") -> None: + checks.append({"name": name, "status": "passed" if passed else "failed", "detail": detail}) + + add("schema.task_id", bool(task_id)) + add("patch.exists", patch_file.exists(), rel(patch_file) if patch_file.exists() else "missing patch file") + add("patch.non_empty", bool(patch_hash), "sha256 present" if patch_hash else "missing or empty patch") + if patch_bundle: + errors = dispatch.validate_patch_json(patch_json_path(run_dir, task_id)) + add("factory_patch.schema", not errors, "; ".join(errors)) + validation_status = str(record.get("validation_status") or "") + add("worker.validation", validation_status in {"passed", "pass", "ok"}, validation_status or "missing") + registry_status, registry_reason = docs_gate(changed_files) + add("docs.registry_gate", registry_status != "failed", registry_reason) + apply_check = run(["git", "apply", "--check", str(patch_file)], cwd=ROOT, timeout=60) if patch_file.exists() and patch_hash else { + "exit_code": 1, + "passed": False, + "stderr_tail": "patch file missing", + } + add("git.apply_check", bool(apply_check.get("passed")), str(apply_check.get("stderr_tail") or apply_check.get("stdout_tail") or "")) + + decision = "passed" if all(item["status"] == "passed" for item in checks) else "failed" + payload = { + "schema_version": "factory-validation-fanout-candidate/v1", + "run_id": run_dir.name, + "task_id": task_id, + "decision": decision, + "cache": "miss", + "cache_key": cache_key, + "patch_hash": patch_hash, + "patch_file": rel(patch_file) if patch_file else "", + "changed_files": changed_files, + "suites": suites, + "checks": checks, + "git_apply_check": apply_check, + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + "validated_at": now_iso(), + "ai_calls_used": 0, + } + write_json(cache_path, payload) + return payload + + +def validate_fanout(run_dir: Path, *, max_parallel: int = 32) -> dict[str, Any]: + apply_plan = create_apply_plan(run_dir) + cache_dir = integration_dir(run_dir) / "validation-cache" + cache_dir.mkdir(parents=True, exist_ok=True) + records = [item for item in apply_plan.get("candidates") or [] if isinstance(item, dict)] + results: list[dict[str, Any]] = [] + started = time.perf_counter() + worker_count = max(1, min(int(max_parallel or 1), 128, len(records) or 1)) + with ThreadPoolExecutor(max_workers=worker_count) as pool: + future_map = {pool.submit(validate_fanout_candidate, run_dir, record, cache_dir=cache_dir): record for record in records} + for future in as_completed(future_map): + result = future.result() + results.append(result) + results.sort(key=lambda item: str(item.get("task_id") or "")) + cache_hits = sum(1 for item in results if item.get("cache") == "hit") + failed = [item for item in results if item.get("decision") != "passed"] + payload = { + "schema_version": "factory-validation-fanout/v1", + "run_id": run_dir.name, + "status": "passed" if records and not failed else ("blocked" if not records else "failed"), + "candidate_count": len(records), + "passed_count": len(records) - len(failed), + "failed_count": len(failed), + "max_parallel": worker_count, + "cache_hits": cache_hits, + "cache_misses": len(records) - cache_hits, + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + "results": results, + "generated_at": now_iso(), + "ai_calls_used": 0, + } + write_json(integration_dir(run_dir) / "validation-fanout.json", payload) + append_jsonl( + integration_dir(run_dir) / "validation-fanout-log.jsonl", + {"ts": now_iso(), "event": "validation_fanout_completed", "status": payload["status"], "candidate_count": len(records), "cache_hits": cache_hits}, + ) + merge_readiness(run_dir) + update_integration_state(run_dir) + return payload + + def read_branch_metadata(run_dir: Path) -> dict[str, Any]: path = integration_dir(run_dir) / "integration-branch.json" return read_json(path) if path.exists() else {} @@ -408,8 +542,17 @@ def apply_patches( rejected.append({**record, "quarantine": quarantine_patch(run_dir, record, reason, {"phase": "apply_plan"})}) validations: list[dict[str, Any]] = [] apply_log = integration_dir(run_dir) / "apply-log.jsonl" - if apply_log.exists(): - apply_log.unlink() + append_jsonl( + apply_log, + { + "ts": now_iso(), + "event": "apply_started", + "run_id": run_dir.name, + "branch": branch or str(branch_meta.get("branch") or default_branch(run_dir)), + "limit": int(limit or 0), + "validate_each": bool(validate_each), + }, + ) candidates = list(apply_plan.get("candidates") or []) if limit > 0: candidates = candidates[:limit] @@ -528,10 +671,11 @@ def registry_gate(run_dir: Path) -> dict[str, Any]: return payload -def merge_readiness(run_dir: Path) -> dict[str, Any]: +def merge_readiness(run_dir: Path, *, auto_merge: bool = False) -> dict[str, Any]: applied = read_json(integration_dir(run_dir) / "applied-patches.json") if (integration_dir(run_dir) / "applied-patches.json").exists() else {"patches": []} rejected = read_json(integration_dir(run_dir) / "rejected-patches.json") if (integration_dir(run_dir) / "rejected-patches.json").exists() else {"patches": []} validation = read_json(integration_dir(run_dir) / "validation-after-each-patch.json") if (integration_dir(run_dir) / "validation-after-each-patch.json").exists() else {"validations": []} + fanout = read_json(integration_dir(run_dir) / "validation-fanout.json") if (integration_dir(run_dir) / "validation-fanout.json").exists() else {} registry = registry_gate(run_dir) branch = read_branch_metadata(run_dir) blockers = [] @@ -545,21 +689,37 @@ def merge_readiness(run_dir: Path) -> dict[str, Any]: blockers.append("registry_gate_failed") if not branch: blockers.append("integration_branch_missing") + if fanout and fanout.get("status") != "passed": + blockers.append("validation_fanout_failed") + rollback = read_json(integration_dir(run_dir) / "rollback-plan.json") if (integration_dir(run_dir) / "rollback-plan.json").exists() else {} + if auto_merge and not rollback: + blockers.append("rollback_plan_missing") + decision = "not_ready" if blockers else ("ready_for_auto_merge" if auto_merge else "ready_for_human_merge_review") + residual_risk = ( + [ + "Automatic merge is allowed only through `cento factory merge --auto-merge-main` after post-merge validation.", + "Cross-node build farm validation is deferred.", + ] + if decision == "ready_for_auto_merge" + else [ + "No automatic merge to main was performed.", + "Human review is still required before merging the integration branch.", + "Cross-node build farm validation is deferred.", + ] + ) payload = { "schema_version": "factory-merge-readiness/v1", "run_id": run_dir.name, - "decision": "ready_for_human_merge_review" if not blockers else "not_ready", + "decision": decision, "blockers": blockers, "applied_count": len(applied.get("patches") or []), "rejected_count": len(rejected.get("patches") or []), "validation_count": len(validation.get("validations") or []), + "validation_fanout": fanout.get("status", ""), "registry_gate": registry.get("status"), "branch": branch, - "residual_risk": [ - "No automatic merge to main was performed.", - "Human review is still required before merging the integration branch.", - "Cross-node build farm validation is deferred.", - ], + "auto_merge_requested": bool(auto_merge), + "residual_risk": residual_risk, "generated_at": now_iso(), "ai_calls_used": 0, } @@ -645,9 +805,11 @@ def update_integration_state(run_dir: Path) -> dict[str, Any]: applied = read_json(idir / "applied-patches.json") if (idir / "applied-patches.json").exists() else {"patches": []} rejected = read_json(idir / "rejected-patches.json") if (idir / "rejected-patches.json").exists() else {"patches": apply_plan.get("rejected") or []} validation = read_json(idir / "validation-after-each-patch.json") if (idir / "validation-after-each-patch.json").exists() else {"validations": []} + fanout = read_json(idir / "validation-fanout.json") if (idir / "validation-fanout.json").exists() else {} rollback = read_json(idir / "rollback-plan.json") if (idir / "rollback-plan.json").exists() else {} readiness = read_json(idir / "merge-readiness.json") if (idir / "merge-readiness.json").exists() else {} taskstream = read_json(idir / "taskstream-sync-preview.json") if (idir / "taskstream-sync-preview.json").exists() else {} + merge_receipt = read_json(idir / "merge-receipt.json") if (idir / "merge-receipt.json").exists() else {} payload = { "schema_version": "factory-integration-state/v1", "run_id": run_dir.name, @@ -657,8 +819,10 @@ def update_integration_state(run_dir: Path) -> dict[str, Any]: "applied_patches": applied.get("patches") or [], "rejected_patches": rejected.get("patches") or [], "validation_after_each_patch": validation, + "validation_fanout": fanout, "rollback_plan": rel(idir / "rollback-plan.json") if rollback else "", "merge_readiness": readiness, + "merge_receipt": merge_receipt, "taskstream_sync_preview": taskstream, "release_candidate": rel(idir / "release-candidate.md") if (idir / "release-candidate.md").exists() else "", "residual_risks": rel(idir / "residual-risks.md") if (idir / "residual-risks.md").exists() else "", @@ -686,14 +850,15 @@ def validate_integration_state(path: Path) -> list[str]: return errors -def validate_integrated(run_dir: Path) -> dict[str, Any]: +def validate_integrated(run_dir: Path, *, auto_merge: bool = False) -> dict[str, Any]: state = update_integration_state(run_dir) errors = validate_integration_state(integration_dir(run_dir) / "integration-state.json") - readiness = merge_readiness(run_dir) + readiness = merge_readiness(run_dir, auto_merge=auto_merge) + ready_decisions = {"ready_for_human_merge_review", "ready_for_auto_merge"} payload = { "schema_version": "factory-integrated-validation/v1", "run_id": run_dir.name, - "decision": "approve" if not errors and readiness.get("decision") == "ready_for_human_merge_review" else "blocked", + "decision": "approve" if not errors and readiness.get("decision") in ready_decisions else "blocked", "errors": errors, "merge_readiness": readiness, "integration_state": rel(integration_dir(run_dir) / "integration-state.json"), @@ -702,3 +867,137 @@ def validate_integrated(run_dir: Path) -> dict[str, Any]: } write_json(integration_dir(run_dir) / "integrated-validation.json", payload) return payload + + +def post_merge_commands() -> list[list[str]]: + return [ + ["python3", "-m", "py_compile", "scripts/factory.py", "scripts/factory_integrator_core.py", "scripts/parallel_delivery.py"], + ] + + +def command_result_passed(result: dict[str, Any]) -> bool: + return int(result.get("exit_code") or 0) == 0 and bool(result.get("passed", True)) + + +def auto_merge_main( + run_dir: Path, + *, + target_branch: str = "main", + remote: str = "origin", + push: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: + idir = integration_dir(run_dir) + branch = read_branch_metadata(run_dir) + blockers: list[str] = [] + if not branch: + blockers.append("integration_branch_missing") + worktree_value = str(branch.get("worktree") or "") + worktree = ROOT / worktree_value if worktree_value and not Path(worktree_value).is_absolute() else Path(worktree_value or "") + if not worktree_value or not worktree.exists(): + blockers.append("integration_worktree_missing") + if not (idir / "release-candidate.md").exists(): + blockers.append("release_candidate_missing") + if not (idir / "rollback-plan.json").exists(): + blockers.append("rollback_plan_missing") + + fanout = read_json(idir / "validation-fanout.json") if (idir / "validation-fanout.json").exists() else validate_fanout(run_dir) + if fanout.get("status") != "passed": + blockers.append("validation_fanout_failed") + integrated = validate_integrated(run_dir, auto_merge=True) + if integrated.get("decision") != "approve": + blockers.append("integrated_validation_blocked") + root_status = run(["git", "status", "--porcelain"], cwd=ROOT, timeout=30) + if str(root_status.get("stdout_tail") or "").strip(): + blockers.append("main_worktree_dirty") + current_branch = run(["git", "branch", "--show-current"], cwd=ROOT, timeout=30) + if str(current_branch.get("stdout_tail") or "").strip() != target_branch: + blockers.append(f"current_branch_not_{target_branch}") + + receipt: dict[str, Any] = { + "schema_version": "factory-auto-merge-receipt/v1", + "run_id": run_dir.name, + "target_branch": target_branch, + "remote": remote, + "push_requested": bool(push), + "dry_run": bool(dry_run), + "status": "blocked" if blockers else ("planned" if dry_run else "running"), + "blockers": sorted(set(blockers)), + "integration_branch": branch, + "validation_fanout": rel(idir / "validation-fanout.json"), + "integrated_validation": rel(idir / "integrated-validation.json"), + "commands": [], + "written_at": now_iso(), + } + if blockers or dry_run: + write_json(idir / "merge-receipt.json", receipt) + append_jsonl(idir / "merge-events.jsonl", {"ts": now_iso(), "event": "auto_merge_blocked" if blockers else "auto_merge_planned", "blockers": receipt["blockers"]}) + update_integration_state(run_dir) + return receipt + + pre_merge_validation = [run(command, cwd=worktree, timeout=240) for command in post_merge_commands()] + receipt["pre_merge_validation"] = pre_merge_validation + if not all(command_result_passed(item) for item in pre_merge_validation): + receipt["status"] = "blocked" + receipt["blockers"] = ["pre_merge_validation_failed"] + write_json(idir / "merge-receipt.json", receipt) + append_jsonl(idir / "merge-events.jsonl", {"ts": now_iso(), "event": "auto_merge_blocked", "blockers": receipt["blockers"]}) + update_integration_state(run_dir) + return receipt + + for command in (["git", "add", "-A"], ["git", "commit", "-m", f"Factory integration: {run_dir.name}"]): + result = run(command, cwd=worktree, timeout=120) + receipt["commands"].append(result) + if result["exit_code"] != 0: + receipt["status"] = "blocked" + receipt["blockers"] = ["integration_commit_failed"] + write_json(idir / "merge-receipt.json", receipt) + append_jsonl(idir / "merge-events.jsonl", {"ts": now_iso(), "event": "auto_merge_blocked", "blockers": receipt["blockers"]}) + update_integration_state(run_dir) + return receipt + commit_sha_result = run(["git", "rev-parse", "HEAD"], cwd=worktree, timeout=30) + commit_sha = str(commit_sha_result.get("stdout_tail") or "").strip() + receipt["integration_commit_sha"] = commit_sha + merge_result = run(["git", "merge", "--ff-only", commit_sha], cwd=ROOT, timeout=120) + receipt["commands"].append(merge_result) + if merge_result["exit_code"] != 0: + receipt["status"] = "blocked" + receipt["blockers"] = ["main_merge_failed"] + write_json(idir / "merge-receipt.json", receipt) + append_jsonl(idir / "merge-events.jsonl", {"ts": now_iso(), "event": "auto_merge_blocked", "blockers": receipt["blockers"]}) + update_integration_state(run_dir) + return receipt + + post_validation = [run(command, cwd=ROOT, timeout=240) for command in post_merge_commands()] + receipt["post_merge_validation"] = post_validation + if not all(command_result_passed(item) for item in post_validation): + receipt["status"] = "blocked_after_local_merge" + receipt["blockers"] = ["post_merge_validation_failed_push_blocked"] + write_json(idir / "merge-receipt.json", receipt) + append_jsonl(idir / "merge-events.jsonl", {"ts": now_iso(), "event": "auto_merge_blocked_after_local_merge", "blockers": receipt["blockers"]}) + update_integration_state(run_dir) + return receipt + + receipt["status"] = "merged_local" + receipt["merged_at"] = now_iso() + if push: + push_result = run(["git", "push", remote, target_branch], cwd=ROOT, timeout=240) + push_receipt = { + "schema_version": "factory-push-receipt/v1", + "run_id": run_dir.name, + "remote": remote, + "target_branch": target_branch, + "commit_sha": commit_sha, + "status": "pushed" if push_result["exit_code"] == 0 else "blocked", + "command": push_result, + "written_at": now_iso(), + } + write_json(idir / "push-receipt.json", push_receipt) + receipt["push_receipt"] = rel(idir / "push-receipt.json") + receipt["status"] = "pushed" if push_result["exit_code"] == 0 else "merged_local_push_failed" + if push_result["exit_code"] != 0: + receipt["blockers"] = ["push_failed"] + write_json(idir / "merge-receipt.json", receipt) + append_jsonl(idir / "merge-events.jsonl", {"ts": now_iso(), "event": "auto_merge_completed", "status": receipt["status"], "commit_sha": commit_sha}) + update_integration_state(run_dir) + return receipt diff --git a/scripts/fixtures/industrial_panel/mission-action-model.json b/scripts/fixtures/industrial_panel/mission-action-model.json new file mode 100644 index 0000000..5369615 --- /dev/null +++ b/scripts/fixtures/industrial_panel/mission-action-model.json @@ -0,0 +1,39 @@ +{ + "stats": { + "blocked": 0, + "review": 1, + "queued": 0, + "runs": 0, + "manual": 0, + "cluster": 0, + "actions": 1 + }, + "brief": { + "objective": "Exercise hero action execution.", + "next_action": "Safe Python action", + "project": "industrial-os-test", + "risk": "fixture only" + }, + "queue": [ + { + "id": "safe-python", + "source": "fixture", + "title": "Safe Python action", + "detail": "contract test action", + "group": "TEST", + "command": ["python3", "-c", "print('hero command ok')"], + "dry_run_command": ["python3", "-c", "print('hero dry-run ok')"], + "context": [ + "Fixture action context", + "Evidence: hero dry-run ok" + ] + } + ], + "context": { + "change_radar": "fixture", + "anti_stall": "fixture", + "blast_radius": "fixture", + "blocker_watch": "fixture", + "session_heat": "#........" + } +} diff --git a/scripts/fixtures/industrial_panel/mission-busy.json b/scripts/fixtures/industrial_panel/mission-busy.json new file mode 100644 index 0000000..e4772fe --- /dev/null +++ b/scripts/fixtures/industrial_panel/mission-busy.json @@ -0,0 +1,126 @@ +{ + "stats": { + "blocked": 1, + "review": 2, + "queued": 1, + "runs": 2, + "manual": 1, + "cluster": 1, + "actions": 5 + }, + "brief": { + "objective": "Route the next Cento mission item from live Taskstream and run state.", + "next_action": "Review ready #101: validation pass with evidence", + "project": "mission-control", + "risk": "1 blocked Taskstream item and degraded cluster" + }, + "queue": [ + { + "id": "issue-101", + "source": "taskstream", + "title": "Review ready #101", + "detail": "Mission router patch | validation pass with evidence", + "group": "REVIEW", + "command": ["./scripts/cento.sh", "agent-work", "review-drain", "--package", "mission-control", "--dry-run"], + "dry_run_command": ["./scripts/cento.sh", "agent-work", "review-drain", "--package", "mission-control", "--dry-run"], + "context": [ + "Issue #101", + "Status: Review", + "Package: mission-control", + "Evidence: workspace/runs/agent-work/101/validation-report.md", + "Safe command: ./scripts/cento.sh agent-work review-drain --package mission-control --dry-run" + ] + }, + { + "id": "issue-102", + "source": "taskstream", + "title": "Review gate #102", + "detail": "Evidence lane docs | review gate failures present", + "group": "REVIEW", + "command": ["./scripts/cento.sh", "agent-work", "show", "102", "--json"], + "dry_run_command": ["./scripts/cento.sh", "agent-work", "show", "102", "--json"], + "context": [ + "Issue #102", + "Status: Review", + "Signal: review gate failures present" + ] + }, + { + "id": "issue-103", + "source": "taskstream", + "title": "Blocked #103", + "detail": "Internal Cento validation gap | missing validation_report", + "group": "BLOCKED", + "command": ["./scripts/cento.sh", "agent-work", "show", "103", "--json"], + "dry_run_command": ["./scripts/cento.sh", "agent-work", "show", "103", "--json"], + "context": [ + "Issue #103", + "Status: Blocked", + "Signal: internal Cento gap" + ] + }, + { + "id": "issue-104", + "source": "taskstream", + "title": "Dispatch dry-run #104", + "detail": "Queued validator | dry-run dispatch to linux", + "group": "QUEUED", + "command": ["./scripts/cento.sh", "agent-work", "dispatch", "104", "--dry-run", "--node", "linux", "--agent", "codex"], + "dry_run_command": ["./scripts/cento.sh", "agent-work", "dispatch", "104", "--dry-run", "--node", "linux", "--agent", "codex"], + "context": [ + "Issue #104", + "Status: Queued", + "Safety: dry-run only" + ] + }, + { + "id": "run-untracked-codex-222", + "source": "agent-runs", + "title": "Manual codex shell", + "detail": "pid 222 | elapsed 05:20 | not attached to Taskstream", + "group": "MANUAL", + "command": ["./scripts/cento.sh", "agent-work", "runs", "--json", "--active"], + "dry_run_command": ["./scripts/cento.sh", "agent-work", "runs", "--json", "--active"], + "context": [ + "Run: untracked-codex-222", + "Runtime: codex", + "Status: untracked_interactive" + ] + }, + { + "id": "cluster-macos", + "source": "cluster", + "title": "Cluster macos: repair stale socket", + "detail": "cluster status=disconnected; stale mesh socket", + "group": "CLUSTER", + "command": ["./scripts/cento.sh", "bridge", "mesh-status"], + "dry_run_command": ["./scripts/cento.sh", "bridge", "mesh-status"], + "context": [ + "Overall: degraded", + "Node: macos", + "Action: repair stale socket" + ] + }, + { + "id": "git-dirty", + "source": "git", + "title": "Dirty worktree check", + "detail": "2 dirty path(s): M scripts/industrial_panel.py", + "group": "GIT", + "command": ["git", "status", "--short"], + "dry_run_command": ["git", "status", "--short"], + "context": [ + "Dirty worktree:", + " M scripts/industrial_panel.py", + "?? scripts/industrial_mission.py" + ] + } + ], + "context": { + "change_radar": "2 dirty path(s): M scripts/industrial_panel.py", + "anti_stall": "1 blocked, 1 review gate gap, 1 manual shell", + "blast_radius": "packages: mission-control, agent-ops; runs=2; jobs=1; cluster=degraded", + "blocker_watch": "cluster status=disconnected; stale mesh socket", + "session_heat": "######..." + } +} diff --git a/scripts/fixtures/industrial_panel/mission-clean.json b/scripts/fixtures/industrial_panel/mission-clean.json new file mode 100644 index 0000000..d581b51 --- /dev/null +++ b/scripts/fixtures/industrial_panel/mission-clean.json @@ -0,0 +1,25 @@ +{ + "stats": { + "blocked": 0, + "review": 0, + "queued": 0, + "runs": 0, + "manual": 0, + "cluster": 0, + "actions": 3 + }, + "brief": { + "objective": "Keep Cento idle state visible without inventing work.", + "next_action": "No actionable mission items from Taskstream, active runs, cluster, git, or jobs.", + "project": "Cento", + "risk": "low: board and cluster are quiet" + }, + "queue": [], + "context": { + "change_radar": "clean worktree", + "anti_stall": "no stall signals from Taskstream", + "blast_radius": "packages: none; runs=0; jobs=0; cluster=healthy", + "blocker_watch": "no blocker details", + "session_heat": "........." + } +} diff --git a/scripts/fixtures/industrial_panel/mission-degraded-data-source.json b/scripts/fixtures/industrial_panel/mission-degraded-data-source.json new file mode 100644 index 0000000..eee959c --- /dev/null +++ b/scripts/fixtures/industrial_panel/mission-degraded-data-source.json @@ -0,0 +1,53 @@ +{ + "stats": { + "blocked": 0, + "review": 0, + "queued": 0, + "runs": 0, + "manual": 0, + "cluster": 1, + "actions": 2 + }, + "brief": { + "objective": "Recover mission state from available Cento sources.", + "next_action": "Cluster macos: repair stale socket", + "project": "Cento", + "risk": "agent-work unavailable: replacement API timeout" + }, + "queue": [ + { + "id": "cluster-macos", + "source": "cluster", + "title": "Cluster macos: repair stale socket", + "detail": "cluster status=disconnected; stale mesh socket", + "group": "CLUSTER", + "command": ["./scripts/cento.sh", "bridge", "mesh-status"], + "dry_run_command": ["./scripts/cento.sh", "bridge", "mesh-status"], + "context": [ + "Overall: degraded", + "Node: macos", + "Action: repair stale socket" + ] + }, + { + "id": "git-dirty", + "source": "git", + "title": "Dirty worktree check", + "detail": "1 dirty path(s): M README.md", + "group": "GIT", + "command": ["git", "status", "--short"], + "dry_run_command": ["git", "status", "--short"], + "context": [ + "Dirty worktree:", + " M README.md" + ] + } + ], + "context": { + "change_radar": "1 dirty path(s): M README.md", + "anti_stall": "no stall signals from Taskstream", + "blast_radius": "packages: none; runs=0; jobs=0; cluster=degraded", + "blocker_watch": "agent-work unavailable: replacement API timeout; stale mesh socket", + "session_heat": "#........" + } +} diff --git a/scripts/fixtures/industrial_panel/mission-sources/busy.json b/scripts/fixtures/industrial_panel/mission-sources/busy.json new file mode 100644 index 0000000..3ba311f --- /dev/null +++ b/scripts/fixtures/industrial_panel/mission-sources/busy.json @@ -0,0 +1,205 @@ +{ + "agent_work": { + "payload": { + "issues": [ + { + "id": 101, + "subject": "Mission router patch", + "status": "Review", + "node": "linux", + "agent": "alice", + "role": "validator", + "package": "mission-control", + "validation_report": "{\"result\":\"pass\",\"result_after_gate\":\"pass\",\"review_gate_failures\":[],\"evidence\":[\"workspace/runs/agent-work/101/validation-report.md\"]}" + }, + { + "id": 102, + "subject": "Evidence lane docs", + "status": "Review", + "node": "linux", + "agent": "validator-pool", + "role": "validator", + "package": "mission-control", + "validation_report": "{\"result\":\"pass\",\"result_after_gate\":\"fail\",\"review_gate_failures\":[\"Review note is missing section: Evidence\"],\"evidence\":[\"workspace/runs/agent-work/102/validation-report.md\"]}" + }, + { + "id": 103, + "subject": "Internal Cento validation gap", + "status": "Blocked", + "node": "linux", + "agent": "validator-pool", + "role": "validator", + "package": "agent-ops", + "validation_report": "" + }, + { + "id": 104, + "subject": "Queued validator", + "status": "Queued", + "node": "linux", + "agent": "codex", + "role": "builder", + "package": "mission-control", + "validation_report": "" + } + ] + }, + "error": null + }, + "runs": { + "payload": { + "runs": [ + { + "run_id": "issue-104-running", + "issue_id": 104, + "runtime": "codex", + "status": "running", + "health": "running", + "pid": 111, + "elapsed": "00:04:00" + }, + { + "run_id": "untracked-codex-222", + "issue_id": null, + "runtime": "codex", + "status": "untracked_interactive", + "health": "untracked", + "pid": 222, + "command": "node /home/alice/.npm-global/bin/codex", + "elapsed": "05:20" + } + ], + "count": 2 + }, + "error": null + }, + "cluster": { + "payload": { + "updated_at": "2026-05-06T18:00:00+00:00", + "nodes": [ + { + "id": "linux", + "platform": "linux", + "socket": "/tmp/cento-linux.sock" + }, + { + "id": "macos", + "platform": "macos", + "socket": "/tmp/cento-mac.sock" + } + ], + "relay": { + "host": "relay.example" + }, + "health": { + "overall": "degraded", + "local": "linux", + "counts": { + "online": 1, + "offline": 0, + "degraded": 1 + }, + "nodes": [ + { + "id": "linux", + "platform": "linux", + "role": "worker", + "state": "online", + "is_local": true, + "socket": "/tmp/cento-linux.sock", + "socket_present": true, + "reasons": [], + "remediation": { + "severity": "ok", + "owner": "local operator", + "action": "monitor", + "commands": ["cento cluster status"] + } + }, + { + "id": "macos", + "platform": "macos", + "role": "worker", + "state": "degraded", + "is_local": false, + "socket": "/tmp/cento-mac.sock", + "socket_present": true, + "reasons": ["cluster status=disconnected", "stale mesh socket"], + "remediation": { + "severity": "warning", + "owner": "macos operator", + "action": "repair stale socket", + "commands": ["cento bridge mesh-status", "cento cluster heal macos"] + } + } + ], + "reasons": ["cluster status=disconnected", "stale mesh socket"], + "actions": [ + { + "node": "macos", + "severity": "warning", + "owner": "macos operator", + "action": "repair stale socket", + "commands": ["cento bridge mesh-status", "cento cluster heal macos"] + } + ] + }, + "status": { + "ok": true, + "stdout": "nodes\nlinux connected\nmacos disconnected", + "stderr": "" + }, + "mesh": { + "ok": true, + "stdout": "srw------- /tmp/cento-mac.sock", + "stderr": "" + } + }, + "error": null + }, + "git": { + "status_short": " M scripts/industrial_panel.py\n?? scripts/industrial_mission.py", + "error": null + }, + "jobs": { + "payload": { + "jobs": [ + { + "id": "job-1", + "status": "running", + "job_summary": { + "state": "ok" + } + } + ], + "counts": { + "running": 1 + }, + "states": { + "ok": 1 + } + }, + "error": null + }, + "actions": { + "payload": [ + { + "id": "cluster_status", + "label": "Cluster status", + "allowlist": ["linux", "macos"], + "command": ["./scripts/cento.sh", "cluster", "status"], + "dry_run_command": ["./scripts/cento.sh", "cluster", "status"], + "availability_check": "always" + }, + { + "id": "cluster_heal", + "label": "Repair degraded cluster", + "allowlist": ["linux", "macos"], + "command": ["./scripts/cento.sh", "cluster", "heal"], + "dry_run_command": ["./scripts/cento.sh", "cluster", "heal", "--help"], + "availability_check": "degraded_nodes" + } + ], + "error": null + } +} diff --git a/scripts/fixtures/industrial_panel/mission-sources/clean.json b/scripts/fixtures/industrial_panel/mission-sources/clean.json new file mode 100644 index 0000000..f2ebdd7 --- /dev/null +++ b/scripts/fixtures/industrial_panel/mission-sources/clean.json @@ -0,0 +1,92 @@ +{ + "agent_work": { + "payload": { + "issues": [] + }, + "error": null + }, + "runs": { + "payload": { + "runs": [], + "count": 0 + }, + "error": null + }, + "cluster": { + "payload": { + "updated_at": "2026-05-06T18:00:00+00:00", + "nodes": [ + { + "id": "linux", + "platform": "linux", + "socket": "/tmp/cento-linux.sock" + } + ], + "health": { + "overall": "healthy", + "local": "linux", + "counts": { + "online": 1, + "offline": 0, + "degraded": 0 + }, + "nodes": [ + { + "id": "linux", + "platform": "linux", + "role": "worker", + "state": "online", + "is_local": true, + "socket": "/tmp/cento-linux.sock", + "socket_present": true, + "reasons": [], + "remediation": { + "severity": "ok", + "owner": "local operator", + "action": "monitor", + "commands": ["cento cluster status"] + } + } + ], + "reasons": [], + "actions": [] + }, + "status": { + "ok": true, + "stdout": "nodes\nlinux connected", + "stderr": "" + }, + "mesh": { + "ok": true, + "stdout": "srw------- /tmp/cento-linux.sock", + "stderr": "" + } + }, + "error": null + }, + "git": { + "status_short": "", + "error": null + }, + "jobs": { + "payload": { + "jobs": [], + "counts": {}, + "states": {} + }, + "error": null + }, + "actions": { + "payload": [ + { + "id": "cluster_status", + "label": "Cluster status", + "allowlist": ["linux", "macos"], + "command": ["./scripts/cento.sh", "cluster", "status"], + "dry_run_command": ["./scripts/cento.sh", "cluster", "status"], + "availability_check": "always" + } + ], + "error": null + } +} diff --git a/scripts/fixtures/industrial_panel/mission-sources/degraded-data-source.json b/scripts/fixtures/industrial_panel/mission-sources/degraded-data-source.json new file mode 100644 index 0000000..dd372af --- /dev/null +++ b/scripts/fixtures/industrial_panel/mission-sources/degraded-data-source.json @@ -0,0 +1,95 @@ +{ + "agent_work": { + "payload": {}, + "error": "replacement API timeout" + }, + "runs": { + "payload": {}, + "error": "agent run ledger unavailable" + }, + "cluster": { + "payload": { + "updated_at": "2026-05-06T18:00:00+00:00", + "nodes": [ + { + "id": "macos", + "platform": "macos", + "socket": "/tmp/cento-mac.sock" + } + ], + "health": { + "overall": "degraded", + "local": "linux", + "counts": { + "online": 0, + "offline": 0, + "degraded": 1 + }, + "nodes": [ + { + "id": "macos", + "platform": "macos", + "role": "worker", + "state": "degraded", + "is_local": false, + "socket": "/tmp/cento-mac.sock", + "socket_present": true, + "reasons": ["stale mesh socket"], + "remediation": { + "severity": "warning", + "owner": "macos operator", + "action": "repair stale socket", + "commands": ["cento bridge mesh-status", "cento cluster heal macos"] + } + } + ], + "reasons": ["stale mesh socket"], + "actions": [ + { + "node": "macos", + "severity": "warning", + "owner": "macos operator", + "action": "repair stale socket", + "commands": ["cento bridge mesh-status", "cento cluster heal macos"] + } + ] + }, + "status": { + "ok": true, + "stdout": "nodes\nmacos disconnected", + "stderr": "" + }, + "mesh": { + "ok": true, + "stdout": "srw------- /tmp/cento-mac.sock", + "stderr": "" + } + }, + "error": null + }, + "git": { + "status_short": " M README.md", + "error": null + }, + "jobs": { + "payload": { + "jobs": [], + "counts": {}, + "states": {} + }, + "error": null + }, + "actions": { + "payload": [ + { + "id": "cluster_status", + "label": "Cluster status", + "allowlist": ["linux", "macos"], + "command": ["./scripts/cento.sh", "cluster", "status"], + "dry_run_command": ["./scripts/cento.sh", "cluster", "status"], + "availability_check": "always" + } + ], + "error": null + } +} diff --git a/scripts/industrial_aux_tui.go b/scripts/industrial_aux_tui.go index 5353156..527c0ec 100644 --- a/scripts/industrial_aux_tui.go +++ b/scripts/industrial_aux_tui.go @@ -65,21 +65,23 @@ type quickAction struct { } type agentRun struct { - RunID string `json:"run_id"` - IssueID interface{} `json:"issue_id"` - Package string `json:"package"` - Node string `json:"node"` - Agent string `json:"agent"` - Role string `json:"role"` - Runtime string `json:"runtime"` - Model string `json:"model"` - Command string `json:"command"` - PID interface{} `json:"pid"` - Status string `json:"status"` - Health string `json:"health"` - LogPath string `json:"log_path"` - Elapsed string `json:"elapsed"` - UpdatedAt string `json:"updated_at"` + RunID string `json:"run_id"` + IssueID interface{} `json:"issue_id"` + IssueSubject string `json:"issue_subject"` + Package string `json:"package"` + Node string `json:"node"` + Agent string `json:"agent"` + Role string `json:"role"` + Runtime string `json:"runtime"` + Model string `json:"model"` + Command string `json:"command"` + PID interface{} `json:"pid"` + Status string `json:"status"` + Health string `json:"health"` + LogPath string `json:"log_path"` + CWD string `json:"cwd"` + Elapsed string `json:"elapsed"` + UpdatedAt string `json:"updated_at"` } type agentIssue struct { @@ -677,7 +679,7 @@ func (m auxModel) renderAgents(width int) string { if m.agentErr != "" { rows = append(rows, badgeStyle("WARN").Render("WARN")+" "+auxTextStyle.Render(clip(m.agentErr, width-8))) } else { - processRows := agentProcessRuns(m.agents, 5) + processRows := agentProcessRuns(m.agents, 4) if len(processRows) > 0 { rows = append(rows, auxMutedStyle.Render("Running now")) for _, run := range processRows { @@ -778,6 +780,7 @@ func loadAgentBoardCmd(root string) tea.Cmd { if err := json.Unmarshal(listOut, &issuesPayload); err != nil { return agentBoardLoadedMsg{runs: runsPayload.Runs, err: err} } + runsPayload.Runs = enrichAgentRunSubjects(runsPayload.Runs, issuesPayload.Issues) managerSummary := agentManagerSummary{} managerCmd := exec.Command("python3", filepath.Join(root, "scripts", "agent_manager.py"), "scan", "--json") managerCmd.Dir = root @@ -891,15 +894,51 @@ func agentLine(run agentRun, width int) string { role = "shell" } row := fmt.Sprintf( - "(%s -> %s -> %s -> %s -> %s)", - strings.ToLower(runtimeName(runtime)), + "%s %s %s %s %s", + runtimeName(runtime), target, role, processStatusLabel(status, health), compactElapsed(run.Elapsed), ) prefix := " " + icon + " " - return prefix + auxTextStyle.Bold(true).Render(clip(row, max(8, width-lipgloss.Width(prefix)))) + line1 := prefix + auxTextStyle.Bold(true).Render(clip(row, max(8, width-lipgloss.Width(prefix)))) + doing := agentDoing(run) + if doing == "" { + return line1 + } + line2Prefix := " doing: " + line2 := line2Prefix + auxMutedStyle.Render(clip(doing, max(8, width-lipgloss.Width(line2Prefix)))) + return lipgloss.JoinVertical(lipgloss.Left, line1, line2) +} + +func agentDoing(run agentRun) string { + issue := issueIDValue(run.IssueID) + subject := strings.TrimSpace(run.IssueSubject) + if subject != "" { + if issue > 0 { + return fmt.Sprintf("#%d %s", issue, compactSubject(subject)) + } + return compactSubject(subject) + } + if run.Package != "" && issue > 0 { + return fmt.Sprintf("#%d package %s", issue, run.Package) + } + command := compactCommand(run.Command) + cwd := compactPath(run.CWD) + if command != "" && cwd != "" { + return command + " @ " + cwd + } + if command != "" { + return command + } + if cwd != "" { + return "shell @ " + cwd + } + if run.LogPath != "" { + return filepath.Base(run.LogPath) + } + return "" } func workLine(issue agentIssue, width int) string { @@ -1248,6 +1287,48 @@ func compactCommand(command string) string { return filepath.Base(fields[0]) } +func compactPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + if path == home { + return "~" + } + prefix := home + string(os.PathSeparator) + if strings.HasPrefix(path, prefix) { + return "~/" + strings.TrimPrefix(path, prefix) + } + } + return path +} + +func issueIDValue(value interface{}) int { + switch typed := value.(type) { + case nil: + return 0 + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err == nil { + return parsed + } + return 0 + default: + parsed, err := strconv.Atoi(strings.TrimSpace(fmt.Sprintf("%v", typed))) + if err == nil { + return parsed + } + return 0 + } +} + func issueLabel(value interface{}) string { switch typed := value.(type) { case nil: @@ -1264,6 +1345,33 @@ func issueLabel(value interface{}) string { } } +func enrichAgentRunSubjects(runs []agentRun, issues []agentIssue) []agentRun { + if len(runs) == 0 || len(issues) == 0 { + return runs + } + byID := make(map[int]agentIssue, len(issues)) + for _, issue := range issues { + byID[issue.ID] = issue + } + for index := range runs { + issueID := issueIDValue(runs[index].IssueID) + if issueID == 0 { + continue + } + issue, ok := byID[issueID] + if !ok { + continue + } + if strings.TrimSpace(runs[index].IssueSubject) == "" { + runs[index].IssueSubject = issue.Subject + } + if strings.TrimSpace(runs[index].Package) == "" { + runs[index].Package = issue.Package + } + } + return runs +} + func compactElapsed(elapsed string) string { elapsed = strings.TrimSpace(elapsed) if elapsed == "" { diff --git a/scripts/industrial_focus.py b/scripts/industrial_focus.py index c396594..cab4f33 100755 --- a/scripts/industrial_focus.py +++ b/scripts/industrial_focus.py @@ -13,6 +13,7 @@ "discord", "cento-industrial-hero", "cento-industrial-terminal", + "cento-industrial-pet", "cento-industrial-jobs", "cento-industrial-cluster", "cento-industrial-agents", @@ -23,23 +24,25 @@ "left": { "cento-industrial-hero": "discord", "cento-industrial-terminal": "cento-industrial-hero", - "cento-industrial-cluster": "cento-industrial-jobs", + "cento-industrial-cluster": "cento-industrial-pet", "cento-industrial-agents": "cento-industrial-cluster", "cento-industrial-actions": "cento-industrial-agents", }, "right": { "discord": "cento-industrial-hero", "cento-industrial-hero": "cento-industrial-terminal", + "cento-industrial-pet": "cento-industrial-cluster", "cento-industrial-jobs": "cento-industrial-cluster", "cento-industrial-cluster": "cento-industrial-agents", "cento-industrial-agents": "cento-industrial-actions", }, "down": { - "discord": "cento-industrial-jobs", + "discord": "cento-industrial-pet", "cento-industrial-hero": "cento-industrial-cluster", "cento-industrial-terminal": "cento-industrial-agents", }, "up": { + "cento-industrial-pet": "discord", "cento-industrial-jobs": "discord", "cento-industrial-cluster": "cento-industrial-hero", "cento-industrial-agents": "cento-industrial-terminal", diff --git a/scripts/industrial_focus_contract_check.py b/scripts/industrial_focus_contract_check.py new file mode 100644 index 0000000..f4e3e38 --- /dev/null +++ b/scripts/industrial_focus_contract_check.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import industrial_focus + + +def assert_true(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def main() -> int: + targets = industrial_focus.TARGET_CLASSES + graph = industrial_focus.VISUAL_GRAPH + + assert_true("cento-industrial-pet" in targets, "focus targets must include the pet pane") + assert_true("cento-industrial-jobs" in targets, "focus targets should keep legacy jobs fallback") + assert_true(graph["down"]["discord"] == "cento-industrial-pet", "discord down should focus pet") + assert_true(graph["right"]["cento-industrial-pet"] == "cento-industrial-cluster", "pet right should focus cluster") + assert_true(graph["up"]["cento-industrial-pet"] == "discord", "pet up should focus discord") + assert_true(graph["left"]["cento-industrial-cluster"] == "cento-industrial-pet", "cluster left should focus pet") + assert_true(graph["right"]["cento-industrial-jobs"] == "cento-industrial-cluster", "legacy jobs right fallback") + assert_true(graph["up"]["cento-industrial-jobs"] == "discord", "legacy jobs up fallback") + print("industrial focus contract check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/industrial_jobs_tui.go b/scripts/industrial_jobs_tui.go index cb74bcb..3a24c5c 100644 --- a/scripts/industrial_jobs_tui.go +++ b/scripts/industrial_jobs_tui.go @@ -44,6 +44,7 @@ type taskState struct { type jobRow struct { ID string + Source string Status string Feature string Tasks int @@ -174,7 +175,7 @@ func (m model) renderBody(width int) string { if m.data.Err != nil { parts = append(parts, statusStyle("failed").Render(m.data.Err.Error())) } else if len(m.data.Rows) == 0 { - parts = append(parts, mutedStyle.Render("No cluster jobs found.")) + parts = append(parts, mutedStyle.Render("No cluster jobs or autopilot runs found.")) } else { parts = append(parts, m.renderRows(width)) parts = append(parts, "", m.renderDetail(width)) @@ -272,6 +273,7 @@ func (m model) renderDetail(width int) string { nameStyle.Render(clip(row.ID, width)), } lines = append(lines, + mutedStyle.Render("source")+" "+nameStyle.Render(clip(row.Source, width-8)), mutedStyle.Render("step")+" "+nameStyle.Render(clip(row.Step, width-6)), mutedStyle.Render("run ")+" "+nameStyle.Render(clip(row.RunDir, width-6)), mutedStyle.Render("summary")+" "+nameStyle.Render(clip(row.Summary, width-10)), @@ -316,6 +318,7 @@ func tickCmd(interval time.Duration) tea.Cmd { func loadJobs(root string) jobsData { runRoot := os.Getenv("CENTO_CLUSTER_JOBS_ROOT") + explicitClusterRoot := runRoot != "" if runRoot == "" { runRoot = filepath.Join(root, "workspace", "runs", "cluster-jobs") } @@ -369,6 +372,7 @@ func loadJobs(root string) jobsData { } rows = append(rows, jobRow{ ID: record.ID, + Source: "cluster-jobs", Status: status, Feature: firstLine(record.Feature), Tasks: len(record.Tasks), @@ -387,12 +391,492 @@ func loadJobs(root string) jobsData { ModTime: info.ModTime(), }) } + if !explicitClusterRoot || strings.EqualFold(os.Getenv("CENTO_INDUSTRIAL_JOBS_INCLUDE_LIVE"), "1") { + rows = append(rows, loadAutopilotRows(root)...) + rows = append(rows, loadFactoryRows(root)...) + } + counts = map[string]int{} + for _, row := range rows { + counts[normalizeStatus(row.Status)]++ + } sort.Slice(rows, func(i, j int) bool { + if rows[i].ModTime.Equal(rows[j].ModTime) { + return sourceRank(rows[i].Source) < sourceRank(rows[j].Source) + } return rows[i].ModTime.After(rows[j].ModTime) }) return jobsData{Rows: rows, Counts: counts, UpdatedAt: time.Now()} } +func loadAutopilotRows(root string) []jobRow { + runRoot := os.Getenv("CENTO_WALK_AUTOPILOT_ROOT") + if runRoot == "" { + runRoot = filepath.Join(root, "workspace", "runs", "walk-autopilot") + } + entries, err := os.ReadDir(runRoot) + if err != nil { + return nil + } + rows := []jobRow{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + runDir := filepath.Join(runRoot, entry.Name()) + metricsPath := filepath.Join(runDir, "metrics.jsonl") + eventsPath := filepath.Join(runDir, "events.jsonl") + if _, err := os.Stat(metricsPath); err != nil { + if _, eventErr := os.Stat(eventsPath); eventErr != nil { + continue + } + } + metric := lastJSONLine(metricsPath) + event := lastJSONLine(eventsPath) + config := readJSONMap(filepath.Join(runDir, "config.json")) + manifest := readJSONMap(filepath.Join(runDir, "execution-manifest.json")) + updated := latestModTime(metricsPath, eventsPath, filepath.Join(runDir, "handoff.md"), filepath.Join(runDir, "factory_promotion.json")) + if eventTime, ok := parseTime(stringValue(event["written_at"])); ok && eventTime.After(updated) { + updated = eventTime + } + if metricTime, ok := parseTime(stringValue(metric["written_at"])); ok && metricTime.After(updated) { + updated = metricTime + } + if updated.IsZero() { + updated = time.Now() + } + + status := normalizeStatus(stringValue(metric["status"])) + if status == "unknown" { + status = normalizeStatus(stringValue(event["status"])) + } + eventName := stringValue(event["event"]) + eventStatus := normalizeStatus(stringValue(event["status"])) + reasons := []string{} + if strings.Contains(eventStatus, "failed") || eventStatus == "error" { + status = "failed" + reasons = append(reasons, fmt.Sprintf("%s=%s", eventName, eventStatus)) + } + if eventName == "hard_stop" || status == "failed" { + status = "failed" + } + + expectedExec := firstInt(manifest["proreq_execution_count"], config["proreq_execution_count"]) + completedExec := intValue(metric["completed_proreq_executions"]) + expectedCalls := firstInt(manifest["expected_proreq_call_count"], config["target_proreq_calls"], config["min_proreq_calls"]) + completedCalls := intValue(metric["proreq_call_count"]) + expectedSwarm := firstInt(manifest["patch_swarm_milestone_count"], config["expected_patch_swarm_runs"]) + completedSwarm := intValue(metric["patch_swarm_runs"]) + expectedReceipts := firstInt(manifest["expected_candidate_patch_receipts"], config["expected_candidate_patch_receipts"], config["target_candidate_patch_receipts"]) + completedReceipts := intValue(metric["candidate_patch_receipts"]) + if completedExec == 0 { + completedExec = intValue(event["execution_index"]) + } + + step := latestAutopilotStep(eventName, eventStatus, completedExec, expectedExec, completedCalls, expectedCalls, completedSwarm, expectedSwarm) + state := "ok" + if len(reasons) > 0 || status == "failed" { + state = "degraded" + } + rows = append(rows, jobRow{ + ID: entry.Name(), + Source: "walk-autopilot", + Status: status, + Feature: fmt.Sprintf("Factory scale autopilot (%s)", fallbackString(stringValue(config["run_mode"]), "walk")), + Tasks: max(completedExec, expectedExec), + Results: completedExec, + Failed: boolInt(state == "degraded"), + Step: step, + Age: ageLabel(updated), + State: state, + Reasons: reasons, + LatestLog: firstExistingPath(eventsPath, metricsPath), + LogTail: logTail(firstExistingPath(eventsPath, metricsPath), 4), + RunDir: runDir, + Summary: filepath.Join(runDir, "handoff.md"), + Command: fmt.Sprintf("cento walk-autopilot factory-scale status --run-id %s --json", entry.Name()), + TasksDetail: []taskState{ + {ID: "proreq", Title: progressLabel("ProReq calls", completedCalls, expectedCalls), Node: "local", ReturnCode: status}, + {ID: "executions", Title: progressLabel("ProReq executions", completedExec, expectedExec), Node: "local", ReturnCode: status}, + {ID: "patch-swarm", Title: progressLabel("Patch Swarm runs", completedSwarm, expectedSwarm), Node: "local", ReturnCode: status}, + {ID: "receipts", Title: progressLabel("Candidate receipts", completedReceipts, expectedReceipts), Node: "local", ReturnCode: status}, + {ID: "latest", Title: fallbackString(step, "latest event"), Node: "local", ReturnCode: status}, + }, + ModTime: updated, + }) + } + return rows +} + +func loadFactoryRows(root string) []jobRow { + runRoot := os.Getenv("CENTO_FACTORY_RUNS_ROOT") + if runRoot == "" { + runRoot = filepath.Join(root, "workspace", "runs", "factory") + } + entries, err := os.ReadDir(runRoot) + if err != nil { + return nil + } + rows := []jobRow{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + runDir := filepath.Join(runRoot, entry.Name()) + planPath := filepath.Join(runDir, "factory-plan.json") + if _, err := os.Stat(planPath); err != nil { + continue + } + plan := readJSONMap(planPath) + queue := readJSONMap(filepath.Join(runDir, "queue", "state.json")) + if len(queue) == 0 { + queue = readJSONMap(filepath.Join(runDir, "queue", "queue.json")) + } + validation := readJSONMap(filepath.Join(runDir, "integration", "validation-fanout.json")) + integration := readJSONMap(filepath.Join(runDir, "integration", "integration-state.json")) + updated := latestModTime( + planPath, + filepath.Join(runDir, "summary.md"), + filepath.Join(runDir, "queue", "state.json"), + filepath.Join(runDir, "queue", "events.jsonl"), + filepath.Join(runDir, "integration", "validation-fanout.json"), + filepath.Join(runDir, "integration", "integration-state.json"), + ) + for _, key := range []string{"generated_at", "updated_at"} { + if parsed, ok := parseTime(stringValue(validation[key])); ok && parsed.After(updated) { + updated = parsed + } + if parsed, ok := parseTime(stringValue(integration[key])); ok && parsed.After(updated) { + updated = parsed + } + } + if updated.IsZero() { + updated = time.Now() + } + + stats := mapValue(queue["stats"]) + total := intValue(stats["total"]) + if total == 0 { + total = len(mapValue(queue["tasks"])) + } + if total == 0 { + total = len(sliceValue(plan["tasks"])) + } + status := factoryStatus(stats, validation, integration) + if status == "succeeded" && total > 0 && len(validation) == 0 && intValue(stats["done"])+intValue(stats["integrated"]) == 0 { + status = "planned" + } + reasons := factoryReasons(status, stats, validation, integration) + step := factoryStep(stats, validation, integration) + rows = append(rows, jobRow{ + ID: entry.Name(), + Source: "factory", + Status: status, + Feature: factoryFeature(plan, queue), + Tasks: total, + Results: intValue(stats["done"]) + intValue(stats["integrated"]) + intValue(validation["passed_count"]), + Failed: intValue(stats["blocked"]) + intValue(stats["deadletter"]) + intValue(validation["failed_count"]), + Step: step, + Age: ageLabel(updated), + State: stateFromReasons(reasons, total), + Reasons: reasons, + LatestLog: firstExistingPath(filepath.Join(runDir, "queue", "events.jsonl"), filepath.Join(runDir, "integration", "validation-fanout.json")), + LogTail: logTail(firstExistingPath(filepath.Join(runDir, "queue", "events.jsonl"), filepath.Join(runDir, "integration", "validation-fanout.json")), 4), + RunDir: runDir, + Summary: filepath.Join(runDir, "summary.md"), + Command: fmt.Sprintf("cento factory status %s --json", runDir), + TasksDetail: factoryTaskStates(queue, validation), + ModTime: updated, + }) + } + return rows +} + +func sourceRank(source string) int { + switch source { + case "walk-autopilot": + return 0 + case "factory": + return 1 + case "cluster-jobs": + return 2 + default: + return 3 + } +} + +func readJSONMap(path string) map[string]any { + raw, err := os.ReadFile(path) + if err != nil { + return map[string]any{} + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return map[string]any{} + } + return payload +} + +func lastJSONLine(path string) map[string]any { + raw, err := os.ReadFile(path) + if err != nil { + return map[string]any{} + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + for index := len(lines) - 1; index >= 0; index-- { + line := strings.TrimSpace(lines[index]) + if line == "" { + continue + } + var payload map[string]any + if err := json.Unmarshal([]byte(line), &payload); err == nil { + return payload + } + } + return map[string]any{} +} + +func latestModTime(paths ...string) time.Time { + var latest time.Time + for _, path := range paths { + if path == "" { + continue + } + info, err := os.Stat(path) + if err != nil || info.IsDir() { + continue + } + if latest.IsZero() || info.ModTime().After(latest) { + latest = info.ModTime() + } + } + return latest +} + +func firstExistingPath(paths ...string) string { + for _, path := range paths { + if path == "" { + continue + } + if info, err := os.Stat(path); err == nil && !info.IsDir() { + return path + } + } + return "" +} + +func stringValue(value any) string { + if value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" { + return "" + } + return text +} + +func fallbackString(value string, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return fallback +} + +func intValue(value any) int { + switch typed := value.(type) { + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) + case json.Number: + parsed, _ := typed.Int64() + return int(parsed) + case string: + var parsed int + if _, err := fmt.Sscanf(strings.TrimSpace(typed), "%d", &parsed); err == nil { + return parsed + } + } + return 0 +} + +func firstInt(values ...any) int { + for _, value := range values { + if parsed := intValue(value); parsed != 0 { + return parsed + } + } + return 0 +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func progressLabel(label string, completed int, expected int) string { + if expected > 0 { + return fmt.Sprintf("%s %d/%d", label, completed, expected) + } + return fmt.Sprintf("%s %d", label, completed) +} + +func latestAutopilotStep(event string, eventStatus string, completedExec int, expectedExec int, completedCalls int, expectedCalls int, completedSwarm int, expectedSwarm int) string { + event = fallbackString(event, "progress") + if eventStatus != "" && eventStatus != "unknown" { + return fmt.Sprintf("%s: %s", strings.ReplaceAll(event, "_", " "), strings.ReplaceAll(eventStatus, "-", " ")) + } + if expectedExec > 0 { + return fmt.Sprintf("executions %d/%d · calls %d/%d · patch swarm %d/%d", completedExec, expectedExec, completedCalls, expectedCalls, completedSwarm, expectedSwarm) + } + return strings.ReplaceAll(event, "_", " ") +} + +func mapValue(value any) map[string]any { + if typed, ok := value.(map[string]any); ok { + return typed + } + return map[string]any{} +} + +func sliceValue(value any) []any { + if typed, ok := value.([]any); ok { + return typed + } + return nil +} + +func factoryStatus(stats map[string]any, validation map[string]any, integration map[string]any) string { + if status := normalizeStatus(stringValue(validation["status"])); status == "failed" || status == "error" || status == "invalid" { + return "failed" + } + if intValue(stats["blocked"])+intValue(stats["deadletter"]) > 0 { + return "failed" + } + if intValue(stats["running"])+intValue(stats["leased"])+intValue(stats["validating"]) > 0 { + return "running" + } + if intValue(stats["queued"])+intValue(stats["waiting"]) > 0 { + return "queued" + } + if intValue(stats["planned"]) > 0 { + return "planned" + } + readiness := mapValue(integration["merge_readiness"]) + if decision := normalizeStatus(stringValue(readiness["decision"])); decision == "not-ready" { + return "failed" + } + return "succeeded" +} + +func factoryReasons(status string, stats map[string]any, validation map[string]any, integration map[string]any) []string { + reasons := []string{} + if status == "failed" { + if failed := intValue(validation["failed_count"]); failed > 0 { + reasons = append(reasons, fmt.Sprintf("%d validation failure(s)", failed)) + } + if blocked := intValue(stats["blocked"]); blocked > 0 { + reasons = append(reasons, fmt.Sprintf("%d blocked task(s)", blocked)) + } + if dead := intValue(stats["deadletter"]); dead > 0 { + reasons = append(reasons, fmt.Sprintf("%d deadletter task(s)", dead)) + } + } + readiness := mapValue(integration["merge_readiness"]) + for _, blocker := range sliceValue(readiness["blockers"]) { + if text := stringValue(blocker); text != "" { + reasons = append(reasons, text) + if len(reasons) >= 3 { + break + } + } + } + return reasons +} + +func factoryStep(stats map[string]any, validation map[string]any, integration map[string]any) string { + if status := normalizeStatus(stringValue(validation["status"])); status != "unknown" { + return fmt.Sprintf("validation fanout %s · %d passed / %d failed", status, intValue(validation["passed_count"]), intValue(validation["failed_count"])) + } + readiness := mapValue(integration["merge_readiness"]) + if decision := normalizeStatus(stringValue(readiness["decision"])); decision != "unknown" { + return fmt.Sprintf("merge readiness %s", strings.ReplaceAll(decision, "-", " ")) + } + total := intValue(stats["total"]) + if total > 0 { + return fmt.Sprintf("queued %d · running %d · done %d / %d", intValue(stats["queued"]), intValue(stats["running"])+intValue(stats["validating"]), intValue(stats["done"])+intValue(stats["integrated"]), total) + } + return "factory plan materialized" +} + +func factoryFeature(plan map[string]any, queue map[string]any) string { + request := mapValue(plan["request"]) + for _, value := range []string{ + stringValue(request["raw"]), + stringValue(plan["feature"]), + stringValue(plan["package"]), + stringValue(queue["package"]), + } { + if value != "" { + return firstLine(value) + } + } + return "Factory run" +} + +func stateFromReasons(reasons []string, total int) string { + if len(reasons) > 0 { + return "degraded" + } + if total == 0 { + return "empty" + } + return "ok" +} + +func factoryTaskStates(queue map[string]any, validation map[string]any) []taskState { + tasks := mapValue(queue["tasks"]) + keys := make([]string, 0, len(tasks)) + for key := range tasks { + keys = append(keys, key) + } + sort.Strings(keys) + states := []taskState{} + for _, key := range keys { + task := mapValue(tasks[key]) + states = append(states, taskState{ + ID: fallbackString(stringValue(task["task_id"]), key), + Title: stringValue(task["title"]), + Node: stringValue(task["node"]), + ReturnCode: normalizeStatus(stringValue(task["status"])), + }) + if len(states) >= 8 { + break + } + } + for _, result := range sliceValue(validation["results"]) { + item := mapValue(result) + if normalizeStatus(stringValue(item["decision"])) != "failed" { + continue + } + states = append(states, taskState{ + ID: fallbackString(stringValue(item["task_id"]), "validation"), + Title: "validation failed", + Node: "local", + ReturnCode: "failed", + Log: stringValue(item["patch_file"]), + }) + if len(states) >= 10 { + break + } + } + return states +} + func artifactPath(record jobRecord, runDir string, key string, fallback string) string { if record.Artifacts != nil { if value := strings.TrimSpace(fmt.Sprint(record.Artifacts[key])); value != "" && value != "" { diff --git a/scripts/industrial_mission.py b/scripts/industrial_mission.py new file mode 100644 index 0000000..ed8aa89 --- /dev/null +++ b/scripts/industrial_mission.py @@ -0,0 +1,845 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import platform +import shlex +import subprocess +import sys +from collections import Counter +from datetime import datetime +from pathlib import Path +from typing import Any + +from jobs_server import load_jobs +from network_web_server import build_cluster_panel_model, cluster_snapshot + + +ROOT_DIR = Path(__file__).resolve().parent.parent +ACTION_REGISTRY = ROOT_DIR / "data" / "industrial-actions.json" +MISSION_FIXTURE_ENV = "CENTO_INDUSTRIAL_MISSION_FIXTURE" +SAFE_COMMANDS = {"./scripts/cento.sh", "cento", "python", "python3", sys.executable} +UNSAFE_COMMANDS = {"sh", "bash", "zsh", "fish", "ksh", "csh", "tcsh", "dash"} +SAFE_GIT_SUBCOMMANDS = {"status", "diff", "log", "show", "branch", "rev-parse"} +ACTIVE_JOB_STATUSES = {"running", "planned", "queued", "dry-run", "invalid", "unknown"} + + +def normalize_platform_name(value: str) -> str: + value = value.lower() + if value == "darwin": + return "macos" + return value + + +def read_json_file(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def command_to_text(command: Any) -> str: + if isinstance(command, list): + return " ".join(str(piece) for piece in command) + return str(command or "") + + +def normalize_command(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + try: + return [piece for piece in shlex.split(value) if piece] + except ValueError: + return [] + if isinstance(value, list): + return [str(piece) for piece in value if str(piece).strip()] + return [] + + +def command_is_safe(command: Any) -> tuple[bool, str]: + if not isinstance(command, list): + return False, "invalid command" + if not command: + return False, "no command configured" + first = str(command[0]).strip() + if not first: + return False, "no command configured" + if first in UNSAFE_COMMANDS: + return False, f"unsafe shell wrapper blocked: {first}" + if first == "git": + subcommand = str(command[1]).strip() if len(command) > 1 else "" + if subcommand in SAFE_GIT_SUBCOMMANDS: + return True, "" + return False, f"unsafe git command blocked: {subcommand or 'missing subcommand'}" + if first in SAFE_COMMANDS: + return True, "" + if first.startswith("./scripts/"): + return True, "" + return False, f"unsafe command blocked: {first}" + + +def run_json(command: list[str], timeout: int = 8) -> tuple[dict[str, Any], str | None]: + try: + result = subprocess.run(command, cwd=ROOT_DIR, capture_output=True, text=True, timeout=timeout, check=False) + except Exception as exc: + return {}, str(exc) + output = (result.stdout or "").strip() + if result.returncode != 0: + error = (result.stderr or output or f"exit {result.returncode}").strip() + return {}, error + try: + payload = json.loads(output) + except json.JSONDecodeError as exc: + return {}, f"invalid JSON from {command_to_text(command)}: {exc}" + return payload if isinstance(payload, dict) else {}, None + + +def run_text(command: list[str], timeout: int = 5) -> tuple[str, str | None]: + try: + result = subprocess.run(command, cwd=ROOT_DIR, capture_output=True, text=True, timeout=timeout, check=False) + except Exception as exc: + return "", str(exc) + output = (result.stdout or result.stderr or "").strip() + if result.returncode != 0: + return output, output or f"exit {result.returncode}" + return output, None + + +def source_bucket(payload: dict[str, Any], key: str) -> tuple[dict[str, Any], str | None]: + value = payload.get(key) or {} + if not isinstance(value, dict): + return {}, f"{key} source fixture must be an object" + data = value.get("payload") + if data is None: + data = value.get("data", {}) + if not isinstance(data, dict): + data = {} + error = value.get("error") + return data, str(error) if error else None + + +def gather_sources() -> dict[str, Any]: + agent_payload, agent_error = run_json(["./scripts/cento.sh", "agent-work", "list", "--json"], timeout=10) + runs_payload, runs_error = run_json(["./scripts/cento.sh", "agent-work", "runs", "--json", "--active"], timeout=10) + try: + cluster_payload = cluster_snapshot() + cluster_error = None + except Exception as exc: + cluster_payload = {} + cluster_error = str(exc) + git_status, git_error = run_text(["git", "status", "--short"], timeout=5) + try: + jobs_payload = load_jobs() + jobs_error = None + except Exception as exc: + jobs_payload = {} + jobs_error = str(exc) + try: + actions_payload = read_json_file(ACTION_REGISTRY) + actions_error = None + except Exception as exc: + actions_payload = [] + actions_error = str(exc) + return { + "agent_work": {"payload": agent_payload, "error": agent_error}, + "runs": {"payload": runs_payload, "error": runs_error}, + "cluster": {"payload": cluster_payload, "error": cluster_error}, + "git": {"status_short": git_status, "error": git_error}, + "jobs": {"payload": jobs_payload, "error": jobs_error}, + "actions": {"payload": actions_payload, "error": actions_error}, + } + + +def issue_status(issue: dict[str, Any]) -> str: + return str(issue.get("status") or "").strip().lower() + + +def issue_id(issue: dict[str, Any]) -> str: + value = issue.get("id") + return str(value) if value is not None else "unknown" + + +def issue_label(issue: dict[str, Any]) -> str: + summary = str(issue.get("tui_summary") or "").strip() + if summary: + return summary + subject = str(issue.get("subject") or "").strip() + if subject: + return subject + return f"Issue #{issue_id(issue)}" + + +def validation_report_status(issue: dict[str, Any]) -> tuple[bool, str, dict[str, Any]]: + raw = str(issue.get("validation_report") or "").strip() + if not raw: + return False, "missing validation_report", {} + try: + report = json.loads(raw) + except json.JSONDecodeError: + return False, "invalid validation_report JSON", {} + if not isinstance(report, dict): + return False, "validation_report is not an object", {} + result = str(report.get("result_after_gate") or report.get("result") or "").strip().lower() + if result != "pass": + return False, f"validation result is {result or 'unknown'}", report + failures = report.get("review_gate_failures") or [] + if failures: + return False, "review gate failures present", report + evidence = report.get("evidence") or [] + if isinstance(evidence, str): + evidence = [evidence] + if not isinstance(evidence, list) or not any(str(item or "").strip() for item in evidence): + return False, "validation evidence missing", report + return True, "validation pass with evidence", report + + +def issue_context(issue: dict[str, Any], reason: str = "") -> list[str]: + lines = [ + f"Issue #{issue_id(issue)}", + f"Status: {issue.get('status') or 'unknown'}", + f"Package: {issue.get('package') or 'default'}", + f"Node: {issue.get('node') or 'unassigned'}", + f"Agent: {issue.get('agent') or 'unassigned'}", + f"Subject: {issue_label(issue)}", + ] + if reason: + lines.append(f"Signal: {reason}") + dispatch = str(issue.get("dispatch") or "").strip() + if dispatch: + lines.append(f"Dispatch: {dispatch}") + passed, validation_reason, report = validation_report_status(issue) + lines.append(f"Validation: {'pass' if passed else validation_reason}") + evidence = report.get("evidence") if isinstance(report, dict) else [] + if isinstance(evidence, str): + evidence = [evidence] + for item in list(evidence or [])[:3]: + lines.append(f"Evidence: {item}") + return lines + + +def show_issue_command(issue: dict[str, Any]) -> list[str]: + return ["./scripts/cento.sh", "agent-work", "show", issue_id(issue), "--json"] + + +def review_drain_dry_run_command(issue: dict[str, Any]) -> list[str]: + package = str(issue.get("package") or "").strip() + if not package: + return show_issue_command(issue) + return ["./scripts/cento.sh", "agent-work", "review-drain", "--package", package, "--dry-run"] + + +def dispatch_dry_run_command(issue: dict[str, Any]) -> list[str]: + command = ["./scripts/cento.sh", "agent-work", "dispatch", issue_id(issue), "--dry-run"] + node = str(issue.get("node") or "").strip() + agent = str(issue.get("agent") or "").strip() + if node: + command.extend(["--node", node]) + if agent: + command.extend(["--agent", agent]) + return command + + +def blocker_reason(issue: dict[str, Any]) -> str: + _passed, reason, _report = validation_report_status(issue) + if reason != "missing validation_report": + return reason + haystack = " ".join( + str(issue.get(key) or "") + for key in ("subject", "description", "package", "dispatch", "validation_report") + ).lower() + if any(term in haystack for term in ("evidence", "artifact", "validation", "manifest", "story.json")): + return "artifact or validation gap" + if any(term in haystack for term in ("cento", "taskstream", "dispatch", "agent-work")): + return "internal Cento gap" + return "blocked Taskstream item" + + +def queue_item( + *, + item_id: str, + source: str, + title: str, + detail: str, + group: str, + command: list[str], + dry_run_command: list[str] | None = None, + context: list[str] | None = None, +) -> dict[str, Any]: + return { + "id": item_id, + "source": source, + "title": title, + "detail": detail, + "group": group, + "key": "", + "command": command, + "dry_run_command": dry_run_command if dry_run_command is not None else command, + "context": context or [], + } + + +def review_ready_items(issues: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows = [] + for issue in issues: + if issue_status(issue) != "review": + continue + passed, reason, _report = validation_report_status(issue) + if not passed: + continue + command = review_drain_dry_run_command(issue) + rows.append( + queue_item( + item_id=f"issue-{issue_id(issue)}", + source="taskstream", + title=f"Review ready #{issue_id(issue)}", + detail=f"{issue_label(issue)} | {reason}", + group="REVIEW", + command=command, + dry_run_command=command, + context=[*issue_context(issue, reason), f"Safe command: {command_to_text(command)}"], + ) + ) + return rows + + +def review_gap_items(issues: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows = [] + for issue in issues: + if issue_status(issue) != "review": + continue + passed, reason, _report = validation_report_status(issue) + if passed: + continue + command = show_issue_command(issue) + rows.append( + queue_item( + item_id=f"issue-{issue_id(issue)}", + source="taskstream", + title=f"Review gate #{issue_id(issue)}", + detail=f"{issue_label(issue)} | {reason}", + group="REVIEW", + command=command, + dry_run_command=command, + context=issue_context(issue, reason), + ) + ) + return rows + + +def blocked_items(issues: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows = [] + for issue in issues: + if issue_status(issue) != "blocked": + continue + reason = blocker_reason(issue) + command = show_issue_command(issue) + rows.append( + queue_item( + item_id=f"issue-{issue_id(issue)}", + source="taskstream", + title=f"Blocked #{issue_id(issue)}", + detail=f"{issue_label(issue)} | {reason}", + group="BLOCKED", + command=command, + dry_run_command=command, + context=issue_context(issue, reason), + ) + ) + return rows + + +def queued_items(issues: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows = [] + for issue in issues: + if issue_status(issue) != "queued": + continue + command = dispatch_dry_run_command(issue) + safe, reason = command_is_safe(command) + if not safe: + continue + rows.append( + queue_item( + item_id=f"issue-{issue_id(issue)}", + source="taskstream", + title=f"Dispatch dry-run #{issue_id(issue)}", + detail=f"{issue_label(issue)} | dry-run dispatch to {issue.get('node') or 'default node'}", + group="QUEUED", + command=command, + dry_run_command=command, + context=[*issue_context(issue, "queued for dry-run dispatch"), f"Safety: {reason or 'dry-run only'}"], + ) + ) + return rows + + +def manual_run_items(runs: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows = [] + command = ["./scripts/cento.sh", "agent-work", "runs", "--json", "--active"] + for run in runs: + if str(run.get("status") or "") != "untracked_interactive": + continue + runtime = str(run.get("runtime") or "agent") + pid = str(run.get("pid") or "") + elapsed = str(run.get("elapsed") or "") + run_id = str(run.get("run_id") or f"manual-{pid or runtime}") + rows.append( + queue_item( + item_id=f"run-{run_id}", + source="agent-runs", + title=f"Manual {runtime} shell", + detail=f"pid {pid or 'unknown'} | elapsed {elapsed or 'unknown'} | not attached to Taskstream", + group="MANUAL", + command=command, + dry_run_command=command, + context=[ + f"Run: {run_id}", + f"Runtime: {runtime}", + f"Status: {run.get('status') or 'unknown'}", + f"Health: {run.get('health') or 'unknown'}", + f"Command: {run.get('command') or 'n/a'}", + ], + ) + ) + return rows + + +def command_from_text(value: str) -> list[str]: + command = normalize_command(value) + if not command: + return [] + if command[0] == "cento": + command = ["./scripts/cento.sh", *command[1:]] + return command + + +def diagnostic_cluster_command(action: dict[str, Any]) -> list[str]: + commands = [str(item) for item in (action.get("commands") or []) if str(item).strip()] + for raw in commands: + command = command_from_text(raw) + if not command: + continue + lowered = [piece.lower() for piece in command] + if "heal" in lowered: + continue + safe, _reason = command_is_safe(command) + if safe: + return command + return ["./scripts/cento.sh", "cluster", "status"] + + +def cluster_items(cluster_payload: dict[str, Any], cluster_error: str | None) -> tuple[list[dict[str, Any]], int, list[str], str]: + if cluster_error: + command = ["./scripts/cento.sh", "cluster", "status"] + return ( + [ + queue_item( + item_id="cluster-unavailable", + source="cluster", + title="Cluster status unavailable", + detail=cluster_error, + group="CLUSTER", + command=command, + dry_run_command=command, + context=[f"Cluster error: {cluster_error}", f"Safe command: {command_to_text(command)}"], + ) + ], + 1, + [cluster_error], + "unavailable", + ) + if not cluster_payload: + return [], 0, [], "unknown" + try: + panel = build_cluster_panel_model(cluster_payload) + except Exception as exc: + command = ["./scripts/cento.sh", "cluster", "status"] + return ( + [ + queue_item( + item_id="cluster-model-error", + source="cluster", + title="Cluster model error", + detail=str(exc), + group="CLUSTER", + command=command, + dry_run_command=command, + context=[f"Cluster model error: {exc}"], + ) + ], + 1, + [str(exc)], + "degraded", + ) + overall = str(panel.get("overall") or "unknown") + counts = panel.get("counts") or {} + issue_count = int(counts.get("offline", 0) or 0) + int(counts.get("degraded", 0) or 0) + reasons = [str(reason) for reason in (panel.get("degraded_reasons") or []) if str(reason).strip()] + if overall in {"healthy", "empty"} and issue_count == 0: + return [], 0, reasons, overall + rows = [] + for action in (panel.get("remediation_actions") or [])[:3]: + if not isinstance(action, dict): + continue + node = str(action.get("node") or "cluster") + label = str(action.get("action") or "inspect cluster") + command = diagnostic_cluster_command(action) + rows.append( + queue_item( + item_id=f"cluster-{node}", + source="cluster", + title=f"Cluster {node}: {label}", + detail="; ".join(reasons[:2]) or f"overall={overall}", + group="CLUSTER", + command=command, + dry_run_command=command, + context=[ + f"Overall: {overall}", + f"Node: {node}", + f"Action: {label}", + f"Owner: {action.get('owner') or 'local operator'}", + f"Reasons: {'; '.join(reasons[:4]) or 'n/a'}", + f"Safe command: {command_to_text(command)}", + ], + ) + ) + if not rows: + command = ["./scripts/cento.sh", "cluster", "status"] + rows.append( + queue_item( + item_id="cluster-status", + source="cluster", + title="Cluster status check", + detail=f"overall={overall}", + group="CLUSTER", + command=command, + dry_run_command=command, + context=[f"Overall: {overall}", f"Reasons: {'; '.join(reasons[:4]) or 'n/a'}"], + ) + ) + return rows, max(issue_count, len(rows)), reasons, overall + + +def git_items(status_short: str, git_error: str | None) -> tuple[list[dict[str, Any]], int, list[str]]: + if git_error: + command = ["git", "status", "--short"] + return ( + [ + queue_item( + item_id="git-status-error", + source="git", + title="Git status unavailable", + detail=git_error, + group="GIT", + command=command, + dry_run_command=command, + context=[f"Git error: {git_error}"], + ) + ], + 1, + [git_error], + ) + lines = [line for line in (status_short or "").splitlines() if line.strip()] + if not lines: + return [], 0, [] + command = ["git", "status", "--short"] + detail = f"{len(lines)} dirty path(s): {lines[0].strip()}" + return ( + [ + queue_item( + item_id="git-dirty", + source="git", + title="Dirty worktree check", + detail=detail, + group="GIT", + command=command, + dry_run_command=command, + context=["Dirty worktree:", *lines[:8], f"Safe command: {command_to_text(command)}"], + ) + ], + len(lines), + lines, + ) + + +def load_action_rows(actions_payload: Any, cluster_payload: dict[str, Any], cluster_error: str | None) -> list[dict[str, Any]]: + payload = actions_payload + if isinstance(payload, dict): + payload = payload.get("actions") or [] + if not isinstance(payload, list): + return [] + platform_name = normalize_platform_name(platform.system()) + health = cluster_payload.get("health") if isinstance(cluster_payload, dict) else {} + nodes = (health or {}).get("nodes") if isinstance(health, dict) else [] + rows = [] + for index, item in enumerate(payload, 1): + if not isinstance(item, dict): + continue + command = normalize_command(item.get("command")) + dry_run = normalize_command(item.get("dry_run_command")) or command + allowlist = [str(value).lower() for value in (item.get("allowlist") or [])] + if allowlist and platform_name not in allowlist: + continue + safe, _reason = command_is_safe(command) + dry_safe, _dry_reason = command_is_safe(dry_run) + if not safe or not dry_safe: + continue + policy = str(item.get("availability_check") or "always") + if cluster_error and policy != "always": + continue + if policy == "non_empty_cluster" and not nodes: + continue + if policy == "degraded_nodes" and not any(str(node.get("state") or "") in {"degraded", "offline"} for node in nodes or []): + continue + rows.append( + { + "id": str(item.get("id") or f"action-{index}"), + "label": str(item.get("label") or item.get("name") or f"Action {index}"), + "command": command, + "dry_run_command": dry_run, + } + ) + return rows + + +def active_job_count(jobs_payload: dict[str, Any]) -> int: + jobs = [item for item in (jobs_payload.get("jobs") or []) if isinstance(item, dict)] + count = 0 + for job in jobs: + status = str(job.get("status") or (job.get("job_summary") or {}).get("status") or "").strip().lower() + if status in ACTIVE_JOB_STATUSES: + count += 1 + return count + + +def compute_context( + *, + git_lines: list[str], + blocked_count: int, + review_gap_count: int, + manual_count: int, + runs_count: int, + active_jobs: int, + cluster_overall: str, + cluster_reasons: list[str], + source_errors: list[str], + packages: list[str], +) -> dict[str, str]: + change = "clean worktree" + if git_lines: + change = f"{len(git_lines)} dirty path(s): {git_lines[0].strip()}" + stall_parts = [] + if blocked_count: + stall_parts.append(f"{blocked_count} blocked") + if review_gap_count: + stall_parts.append(f"{review_gap_count} review gate gap(s)") + if manual_count: + stall_parts.append(f"{manual_count} manual shell(s)") + anti_stall = ", ".join(stall_parts) if stall_parts else "no stall signals from Taskstream" + package_text = ", ".join(sorted(set(packages))[:4]) if packages else "none" + blast = f"packages: {package_text}; runs={runs_count}; jobs={active_jobs}; cluster={cluster_overall}" + blockers = "; ".join([*source_errors, *cluster_reasons][:3]) or "no blocker details" + heat_score = min(9, blocked_count * 2 + review_gap_count + manual_count + active_jobs + (1 if cluster_overall not in {"healthy", "empty"} else 0)) + heat = ("#" * heat_score) + ("." * max(0, 9 - heat_score)) + return { + "change_radar": change, + "anti_stall": anti_stall, + "blast_radius": blast, + "blocker_watch": blockers, + "session_heat": heat, + } + + +def default_hub() -> list[dict[str, str]]: + return [ + {"key": "j/k", "label": "SELECT", "detail": "move queue selection"}, + {"key": "arrows", "label": "SELECT", "detail": "move queue selection"}, + {"key": "1-9", "label": "JUMP", "detail": "select numbered item"}, + {"key": "a/enter", "label": "RUN", "detail": "run selected safe command"}, + {"key": "d", "label": "DRY RUN", "detail": "run selected dry-run command"}, + {"key": "o", "label": "CONTEXT", "detail": "show selected issue/run/cluster/git detail"}, + {"key": "u", "label": "NOTE", "detail": "draft status note from live state"}, + {"key": "r", "label": "REFRESH", "detail": "reload Cento state"}, + {"key": "?", "label": "HELP", "detail": "show key help"}, + ] + + +def normalize_model(payload: dict[str, Any]) -> dict[str, Any]: + stats = dict(payload.get("stats") or {}) + for key in ("blocked", "review", "queued", "runs", "manual", "cluster", "actions"): + try: + stats[key] = int(stats.get(key) or 0) + except (TypeError, ValueError): + stats[key] = 0 + brief = dict(payload.get("brief") or {}) + brief.setdefault("objective", "Read Cento mission state.") + brief.setdefault("next_action", "No actionable queue item selected.") + brief.setdefault("project", "Cento") + brief.setdefault("risk", "unknown") + queue = [] + for index, raw in enumerate(payload.get("queue") or [], 1): + if not isinstance(raw, dict): + continue + item = dict(raw) + item.setdefault("id", f"item-{index}") + item.setdefault("source", "mission") + item.setdefault("title", f"Mission item {index}") + item.setdefault("detail", "") + item.setdefault("group", "MISSION") + item["key"] = str(index) + item["command"] = normalize_command(item.get("command")) + item["dry_run_command"] = normalize_command(item.get("dry_run_command")) or item["command"] + context = item.get("context") or [] + if isinstance(context, str): + context = [context] + item["context"] = [str(line) for line in context if str(line).strip()] + queue.append(item) + context = payload.get("context") or {} + if isinstance(context, list): + context = {f"line_{index}": str(line) for index, line in enumerate(context, 1)} + if not isinstance(context, dict): + context = {} + hub = payload.get("hub") or default_hub() + if not isinstance(hub, list): + hub = default_hub() + return { + "stats": stats, + "brief": brief, + "queue": queue[:9], + "context": {str(key): str(value) for key, value in context.items()}, + "hub": [dict(item) for item in hub if isinstance(item, dict)], + "updated_at": str(payload.get("updated_at") or datetime.now().astimezone().isoformat(timespec="seconds")), + "sources": payload.get("sources") or {}, + } + + +def build_mission_model_from_sources(sources: dict[str, Any]) -> dict[str, Any]: + agent_payload, agent_error = source_bucket(sources, "agent_work") + runs_payload, runs_error = source_bucket(sources, "runs") + cluster_payload, cluster_error = source_bucket(sources, "cluster") + jobs_payload, jobs_error = source_bucket(sources, "jobs") + actions_payload = (sources.get("actions") or {}).get("payload", []) if isinstance(sources.get("actions") or {}, dict) else [] + actions_error = (sources.get("actions") or {}).get("error") if isinstance(sources.get("actions") or {}, dict) else None + git_source = sources.get("git") or {} + if not isinstance(git_source, dict): + git_source = {} + git_status = str(git_source.get("status_short") or "") + git_error = str(git_source.get("error")) if git_source.get("error") else None + + issues = [item for item in (agent_payload.get("issues") or []) if isinstance(item, dict)] + runs = [item for item in (runs_payload.get("runs") or []) if isinstance(item, dict)] + review_ready = review_ready_items(issues) + review_gaps = review_gap_items(issues) + blocked = blocked_items(issues) + queued = queued_items(issues) + manual = manual_run_items(runs) + cluster_queue, cluster_count, cluster_reasons, cluster_overall = cluster_items(cluster_payload, cluster_error) + git_queue, _dirty_count, git_lines = git_items(git_status, git_error) + action_rows = load_action_rows(actions_payload, cluster_payload, cluster_error) + queue = [*review_ready, *review_gaps, *blocked, *queued, *manual, *cluster_queue, *git_queue] + + source_errors = [ + f"agent-work unavailable: {agent_error}" if agent_error else "", + f"agent-runs unavailable: {runs_error}" if runs_error else "", + f"jobs unavailable: {jobs_error}" if jobs_error else "", + f"actions unavailable: {actions_error}" if actions_error else "", + f"git unavailable: {git_error}" if git_error else "", + ] + source_errors = [item for item in source_errors if item] + packages = [str(issue.get("package") or "") for issue in issues if str(issue.get("package") or "").strip()] + package_counts = Counter(packages) + active_jobs = active_job_count(jobs_payload) + stats = { + "blocked": sum(1 for issue in issues if issue_status(issue) == "blocked"), + "review": sum(1 for issue in issues if issue_status(issue) == "review"), + "queued": sum(1 for issue in issues if issue_status(issue) == "queued"), + "runs": len(runs), + "manual": len(manual), + "cluster": cluster_count, + "actions": len(action_rows), + } + context = compute_context( + git_lines=git_lines, + blocked_count=stats["blocked"], + review_gap_count=len(review_gaps), + manual_count=stats["manual"], + runs_count=stats["runs"], + active_jobs=active_jobs, + cluster_overall=cluster_overall, + cluster_reasons=cluster_reasons, + source_errors=source_errors, + packages=packages, + ) + if queue: + next_action = f"{queue[0]['title']}: {queue[0]['detail']}" + else: + next_action = "No actionable mission items from Taskstream, active runs, cluster, git, or jobs." + top_package = package_counts.most_common(1)[0][0] if package_counts else "Cento" + if source_errors: + risk = source_errors[0] + elif stats["blocked"]: + risk = f"{stats['blocked']} blocked Taskstream item(s)" + elif stats["cluster"]: + risk = f"cluster {cluster_overall}" + elif git_lines: + risk = f"{len(git_lines)} dirty worktree path(s)" + else: + risk = "low: board and cluster are quiet" + objective = "Route the next Cento mission item from live state." + if not queue: + objective = "Keep Cento idle state visible without inventing work." + model = { + "stats": stats, + "brief": { + "objective": objective, + "next_action": next_action, + "project": top_package, + "risk": risk, + }, + "queue": queue, + "context": context, + "hub": default_hub(), + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "sources": { + "agent_work": "error" if agent_error else "ok", + "runs": "error" if runs_error else "ok", + "cluster": "error" if cluster_error else "ok", + "git": "error" if git_error else "ok", + "jobs": "error" if jobs_error else "ok", + "actions": "error" if actions_error else "ok", + }, + } + return normalize_model(model) + + +def build_mission_model() -> dict[str, Any]: + fixture = os.environ.get(MISSION_FIXTURE_ENV, "").strip() + if fixture: + try: + payload = read_json_file(Path(fixture)) + if isinstance(payload, dict): + return normalize_model(payload) + except Exception as exc: + return normalize_model( + { + "stats": {"cluster": 1}, + "brief": { + "objective": "Load deterministic mission fixture.", + "next_action": "Fix the mission fixture path or JSON.", + "project": "Cento", + "risk": f"mission fixture unavailable: {exc}", + }, + "queue": [ + { + "id": "mission-fixture-error", + "source": "fixture", + "title": "Mission fixture error", + "detail": str(exc), + "group": "FIXTURE", + "command": ["git", "status", "--short"], + "dry_run_command": ["git", "status", "--short"], + "context": [str(exc)], + } + ], + "context": {"blocker_watch": str(exc)}, + } + ) + return build_mission_model_from_sources(gather_sources()) diff --git a/scripts/industrial_mission_contract_check.py b/scripts/industrial_mission_contract_check.py new file mode 100644 index 0000000..884e543 --- /dev/null +++ b/scripts/industrial_mission_contract_check.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib +import json +import os +import sys +import tempfile +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +ROOT_DIR = SCRIPT_DIR.parent +FIXTURE_ROOT = SCRIPT_DIR / "fixtures" / "industrial_panel" +SOURCE_ROOT = FIXTURE_ROOT / "mission-sources" +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + + +def assert_true(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def load_json(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + assert_true(isinstance(payload, dict), f"{path} must contain a JSON object") + return payload + + +def queue_ids(model: dict[str, Any]) -> list[str]: + return [str(item.get("id") or "") for item in model.get("queue") or []] + + +def reload_panel(mission_fixture: Path | None = None, receipt_root: Path | None = None): + if mission_fixture: + os.environ["CENTO_INDUSTRIAL_MISSION_FIXTURE"] = str(mission_fixture) + else: + os.environ.pop("CENTO_INDUSTRIAL_MISSION_FIXTURE", None) + if receipt_root: + os.environ["CENTO_INDUSTRIAL_ACTION_RUN_ROOT"] = str(receipt_root) + else: + os.environ.pop("CENTO_INDUSTRIAL_ACTION_RUN_ROOT", None) + import industrial_panel + + return importlib.reload(industrial_panel) + + +def assert_source_models() -> None: + import industrial_mission + + busy = industrial_mission.build_mission_model_from_sources(load_json(SOURCE_ROOT / "busy.json")) + expected = [ + "issue-101", + "issue-102", + "issue-103", + "issue-104", + "run-untracked-codex-222", + "cluster-macos", + "git-dirty", + ] + assert_true(queue_ids(busy) == expected, f"busy queue priority mismatch: {queue_ids(busy)}") + assert_true(busy["stats"]["review"] == 2, f"busy review count mismatch: {busy['stats']}") + assert_true(busy["stats"]["blocked"] == 1, f"busy blocked count mismatch: {busy['stats']}") + assert_true(busy["stats"]["manual"] == 1, f"busy manual count mismatch: {busy['stats']}") + cluster_item = next(item for item in busy["queue"] if item["id"] == "cluster-macos") + assert_true("heal" not in " ".join(cluster_item["command"]), f"hero cluster command must be diagnostic: {cluster_item}") + + hub_text = json.dumps(busy["hub"]).upper() + assert_true("CAPTURE" not in hub_text, "hub must not advertise capture") + assert_true("BLOCK\"" not in hub_text, "hub must not advertise a block action") + assert_true("DRY RUN" in hub_text, "hub should advertise dry-run") + assert_true("CONTEXT" in hub_text, "hub should advertise context") + + clean = industrial_mission.build_mission_model_from_sources(load_json(SOURCE_ROOT / "clean.json")) + assert_true(clean["queue"] == [], f"clean board should not invent queue work: {clean['queue']}") + assert_true(clean["brief"]["risk"].startswith("low"), f"clean risk should be low: {clean['brief']}") + + degraded = industrial_mission.build_mission_model_from_sources(load_json(SOURCE_ROOT / "degraded-data-source.json")) + degraded_ids = queue_ids(degraded) + assert_true(degraded_ids[:2] == ["cluster-macos", "git-dirty"], f"degraded source fallback queue mismatch: {degraded_ids}") + assert_true("agent-work unavailable" in degraded["brief"]["risk"], f"degraded risk missing source error: {degraded['brief']}") + assert_true("stale mesh socket" in degraded["context"]["blocker_watch"], f"degraded context missing cluster detail: {degraded['context']}") + + +def assert_hero_actions_and_context() -> None: + with tempfile.TemporaryDirectory() as tmp: + receipt_root = Path(tmp) + panel = reload_panel(FIXTURE_ROOT / "mission-action-model.json", receipt_root) + panel.HERO_STATE.update({"selected": 0, "message": "ready", "output": [], "last_key": ""}) + + panel.handle_hero_key(panel.HERO_STATE, "d") + output_text = "\n".join(panel.HERO_STATE["output"]) + assert_true("hero dry-run ok" in output_text, f"dry-run output missing signal: {output_text}") + receipts = sorted(receipt_root.glob("*.json")) + assert_true(receipts, "dry-run should write an action receipt") + dry_receipt = json.loads(receipts[-1].read_text(encoding="utf-8")) + assert_true(dry_receipt["dry_run"] is True, f"receipt should mark dry_run true: {dry_receipt}") + assert_true(dry_receipt["selected_item_id"] == "safe-python", f"receipt selected item mismatch: {dry_receipt}") + assert_true("hero dry-run ok" in "\n".join(dry_receipt["output_tail"]), f"receipt missing dry-run output: {dry_receipt}") + + panel.handle_hero_key(panel.HERO_STATE, "a") + output_text = "\n".join(panel.HERO_STATE["output"]) + assert_true("hero command ok" in output_text, f"run output missing signal: {output_text}") + + unsafe = { + "id": "unsafe-shell", + "source": "fixture", + "title": "Unsafe shell", + "group": "TEST", + "command": ["bash", "-lc", "echo should-not-run"], + "dry_run_command": ["bash", "-lc", "echo should-not-run"], + } + blocked = panel.run_hero_action(unsafe) + blocked_text = "\n".join(blocked) + assert_true("BLOCKED:" in blocked_text, f"unsafe command should be blocked: {blocked_text}") + receipts = sorted(receipt_root.glob("*.json")) + blocked_receipt = json.loads(receipts[-1].read_text(encoding="utf-8")) + assert_true(blocked_receipt["status"] == "blocked", f"blocked receipt status mismatch: {blocked_receipt}") + assert_true(blocked_receipt["exit_code"] == 126, f"blocked receipt exit mismatch: {blocked_receipt}") + + panel = reload_panel(FIXTURE_ROOT / "mission-busy.json", None) + panel.HERO_STATE.update({"selected": 0, "message": "ready", "output": [], "last_key": ""}) + panel.handle_hero_key(panel.HERO_STATE, "o") + context_text = "\n".join(panel.HERO_STATE["output"]) + assert_true("Issue #101" in context_text, f"context should include issue detail: {context_text}") + assert_true("workspace/runs/agent-work/101/validation-report.md" in context_text, f"context should include evidence: {context_text}") + assert_true("review-drain" in context_text, f"context should include safe command: {context_text}") + + +def main() -> int: + assert_source_models() + assert_hero_actions_and_context() + print("industrial mission contract check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/industrial_panel.py b/scripts/industrial_panel.py index f879105..d603738 100755 --- a/scripts/industrial_panel.py +++ b/scripts/industrial_panel.py @@ -19,11 +19,13 @@ import unicodedata import shlex import threading +import uuid from datetime import datetime from pathlib import Path from typing import Any from industrial_activity import build_activity_events, classify_severity, dedupe_sort_events, event, filter_activity_events, load_agent_work_payload, parse_timestamp +from industrial_mission import build_mission_model, command_is_safe as mission_command_is_safe, command_to_text as mission_command_text from industrial_status import metrics from jobs_server import load_jobs from network_web_server import build_cluster_panel_model, cluster_snapshot @@ -58,44 +60,6 @@ HOME = "\033[H" CLEAR_TO_END = "\033[J" CLEAR_LINE = "\033[K" -HERO_QUEUE = [ - { - "title": "Finish industrial dashboard", - "detail": "replace hero pane with mission model", - "group": "BUILD", - "key": "a", - "command": ["python3", "-m", "py_compile", "scripts/industrial_panel.py"], - }, - { - "title": "Review funnel docs", - "detail": "extract blockers + next owner", - "group": "DOCS", - "key": "o", - "command": ["python3", "scripts/funnel_check.py"], - }, - { - "title": "Run make check", - "detail": "execute test pack, capture failure", - "group": "VERIFY", - "key": "m", - "command": ["make", "check"], - }, - { - "title": "Draft demo follow-up", - "detail": "6-line Slack update + ask", - "group": "ADOPT", - "key": "u", - "command": None, - }, - { - "title": "Turn repeated step into job", - "detail": "scaffold job from command history", - "group": "AUTO", - "key": "g", - "command": ["python3", "scripts/cluster_job_runner.py", "--help"], - }, -] -HERO_READY_ACTIONS = 12 def normalize_platform_name(value: str) -> str: value = value.lower() if value == "darwin": @@ -118,8 +82,8 @@ def normalize_platform_name(value: str) -> str: } HERO_STATE: dict[str, Any] = { "selected": 0, - "message": "implement action router", - "output": ["j/k or arrows move", "a or enter runs selected action", "o opens context", "u drafts update"], + "message": "ready", + "output": ["j/k or arrows move", "a/enter runs selected safe command", "d dry-runs selected command", "o opens context"], "last_key": "", } ACTIONS_STATE: dict[str, Any] = { @@ -439,18 +403,7 @@ def action_command_text(command: Any) -> str: def action_command_is_safe(command: Any) -> tuple[bool, str]: - if not isinstance(command, list): - return False, "invalid command" - if not command: - return False, "no command configured" - first = str(command[0]).strip() - if not first: - return False, "no command configured" - if first in UNSAFE_ACTION_COMMANDS: - return False, f"unsafe shell wrapper blocked: {first}" - if first in SAFE_ACTION_COMMANDS or first.startswith("./scripts/"): - return True, "" - return False, f"unsafe command blocked: {first}" + return mission_command_is_safe(command) def cluster_panel_payload() -> tuple[dict[str, Any], str | None]: @@ -626,6 +579,116 @@ def run_action(action: dict[str, Any], *, dry_run: bool = False) -> list[str]: return lines +def hero_action_run_root() -> Path: + return Path(os.environ.get("CENTO_INDUSTRIAL_ACTION_RUN_ROOT", ROOT_DIR / "workspace" / "runs" / "industrial-os" / "action-runs")) + + +def slug(value: str) -> str: + cleaned = [] + for char in str(value or "action").lower(): + if char.isalnum() or char in {"-", "_"}: + cleaned.append(char) + elif cleaned and cleaned[-1] != "-": + cleaned.append("-") + result = "".join(cleaned).strip("-") + return result[:60] or "action" + + +def display_receipt_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT_DIR)) + except ValueError: + return str(path) + + +def write_hero_action_receipt( + item: dict[str, Any], + command: list[str], + *, + dry_run: bool, + status: str, + returncode: int | None, + output: str, +) -> Path: + root = hero_action_run_root() + root.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().astimezone().isoformat(timespec="seconds") + filename_stamp = datetime.now().strftime("%Y%m%dT%H%M%S") + receipt = { + "schema": "cento.industrial-os.hero-action-run.v1", + "timestamp": timestamp, + "dry_run": dry_run, + "selected_item_id": str(item.get("id") or ""), + "source": str(item.get("source") or ""), + "title": str(item.get("title") or ""), + "group": str(item.get("group") or ""), + "cwd": str(ROOT_DIR), + "command": [str(piece) for piece in command], + "command_text": mission_command_text(command), + "status": status, + "exit_code": returncode, + "output_tail": [line for line in output.splitlines() if line][-20:], + } + path = root / f"{filename_stamp}-{slug(str(item.get('id') or item.get('title') or 'action'))}-{uuid.uuid4().hex[:8]}.json" + path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return path + + +def run_hero_action(item: dict[str, Any], *, dry_run: bool = False, timeout: float = 12.0) -> list[str]: + command = item.get("dry_run_command" if dry_run else "command") or item.get("command") or [] + if not isinstance(command, list): + command = [] + command = [str(piece) for piece in command if str(piece).strip()] + command_text = mission_command_text(command) + if not command: + receipt = write_hero_action_receipt(item, command, dry_run=dry_run, status="empty", returncode=0, output="No command configured") + return [f"EMPTY: {str(item.get('title') or 'selected item')}", "No command configured.", f"receipt: {display_receipt_path(receipt)}"] + safe, reason = action_command_is_safe(command) + if not safe: + receipt = write_hero_action_receipt(item, command, dry_run=dry_run, status="blocked", returncode=126, output=reason) + return [f"BLOCKED: {command_text}", reason, f"receipt: {display_receipt_path(receipt)}"] + + started = time.time() + try: + result = subprocess.run( + command, + cwd=ROOT_DIR, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout, + check=False, + ) + output = (result.stdout or "").strip() + status = "succeeded" if result.returncode == 0 else "failed" + returncode: int | None = result.returncode + except FileNotFoundError as exc: + output = f"unavailable: {exc}" + status = "unavailable" + returncode = None + except subprocess.TimeoutExpired as exc: + output = (exc.stdout or exc.stderr or f"timed out after {timeout}s") + if isinstance(output, bytes): + output = output.decode(errors="replace") + status = "failed" + returncode = 124 + except Exception as exc: + output = str(exc) + status = "failed" + returncode = None + elapsed = time.time() - started + if not output: + output = f"exit {returncode}" if returncode is not None else status + receipt = write_hero_action_receipt(item, command, dry_run=dry_run, status=status, returncode=returncode, output=output) + output_lines = [line for line in output.splitlines() if line] or [status] + return [ + f"{status.upper()}: {command_text}", + *output_lines[-8:], + f"elapsed {elapsed:.2f}s", + f"receipt: {display_receipt_path(receipt)}", + ] + + def idle_action_result(action: dict[str, Any]) -> dict[str, Any]: return { "label": action.get("label", "") or "action", @@ -695,58 +758,139 @@ def hero_context_lines() -> list[str]: return rows +def hero_queue(model: dict[str, Any]) -> list[dict[str, Any]]: + return [item for item in (model.get("queue") or []) if isinstance(item, dict)] + + +def clamp_hero_selection(state: dict[str, Any], queue: list[dict[str, Any]]) -> int: + if not queue: + state["selected"] = 0 + return 0 + selected = max(0, min(len(queue) - 1, int(state.get("selected", 0)))) + state["selected"] = selected + return selected + + +def hero_item_context_output(item: dict[str, Any]) -> list[str]: + lines = [ + f"{item.get('group') or 'MISSION'} {item.get('title') or 'selected item'}", + f"id={item.get('id') or 'n/a'} source={item.get('source') or 'n/a'}", + f"detail={item.get('detail') or 'n/a'}", + ] + command = item.get("command") or [] + dry_run = item.get("dry_run_command") or [] + if command: + lines.append(f"command={mission_command_text(command)}") + if dry_run: + lines.append(f"dry_run={mission_command_text(dry_run)}") + for line in item.get("context") or []: + if len(lines) >= 10: + break + lines.append(str(line)) + return lines + + +def hero_draft_note(model: dict[str, Any], selected_item: dict[str, Any] | None) -> list[str]: + stats = model.get("stats") or {} + brief = model.get("brief") or {} + selected_title = str((selected_item or {}).get("title") or "no selected queue item") + selected_detail = str((selected_item or {}).get("detail") or "") + return [ + "Draft status note (not submitted)", + f"Objective: {brief.get('objective') or 'n/a'}", + f"Next: {selected_title}" + (f" - {selected_detail}" if selected_detail else ""), + ( + "Counts: " + f"review={stats.get('review', 0)} " + f"blocked={stats.get('blocked', 0)} " + f"queued={stats.get('queued', 0)} " + f"runs={stats.get('runs', 0)} " + f"manual={stats.get('manual', 0)} " + f"cluster={stats.get('cluster', 0)}" + ), + f"Risk: {brief.get('risk') or 'n/a'}", + ] + + +def hero_help_lines(model: dict[str, Any]) -> list[str]: + lines = [] + for item in model.get("hub") or []: + key = str(item.get("key") or "").strip() + label = str(item.get("label") or "").strip() + detail = str(item.get("detail") or "").strip() + if key: + lines.append(f"{key}: {label}" + (f" - {detail}" if detail else "")) + return lines or ["No key bindings configured."] + + def handle_hero_key(state: dict[str, Any], key: str) -> bool: if not key: return True + model = build_mission_model() + queue = hero_queue(model) + max_index = len(queue) - 1 + selected = clamp_hero_selection(state, queue) state["last_key"] = key.replace("\x1b", "esc") - selected = int(state.get("selected", 0)) if key in {"q", "Q", "\x03"}: return False - if key in {"j", "J", "\x1b[B"}: - state["selected"] = min(len(HERO_QUEUE) - 1, selected + 1) + if key in {"j", "J", "\x1b[B", "\x1b[C", "down", "right"}: + state["selected"] = min(max_index, selected + 1) if queue else 0 state["message"] = "selection moved" return True - if key in {"k", "K", "\x1b[A"}: + if key in {"k", "K", "\x1b[A", "\x1b[D", "up", "left"}: state["selected"] = max(0, selected - 1) state["message"] = "selection moved" return True - if key in {"1", "2", "3", "4", "5"}: - state["selected"] = min(len(HERO_QUEUE) - 1, int(key) - 1) + if key in {"1", "2", "3", "4", "5", "6", "7", "8", "9"}: + state["selected"] = min(max_index, int(key) - 1) if queue else 0 state["message"] = "selection moved" return True if key in {"r", "R"}: state["message"] = "refreshed" - state["output"] = ["state rebuilt from jobs, logs, and local metrics"] + stats = model.get("stats") or {} + state["output"] = [ + "Mission model rebuilt from Cento state.", + ( + f"review={stats.get('review', 0)} blocked={stats.get('blocked', 0)} " + f"queued={stats.get('queued', 0)} runs={stats.get('runs', 0)} " + f"cluster={stats.get('cluster', 0)} actions={stats.get('actions', 0)}" + ), + ] return True if key in {"o", "O"}: + if not queue: + state["message"] = "context unavailable" + state["output"] = ["No selected mission queue item.", str((model.get("brief") or {}).get("next_action") or "")] + return True state["message"] = "context opened" - state["output"] = [strip_ansi(line) for line in hero_context_lines()] + state["output"] = hero_item_context_output(queue[int(state.get("selected", 0))]) return True if key in {"u", "U"}: state["message"] = "update drafted" - state["output"] = [ - "EOD update", - "Central action pane is now readable and keyboard-driven.", - "Volcano background remains in place.", - "Next: attach project-specific commands to each action.", - ] + selected_item = queue[int(state.get("selected", 0))] if queue else None + state["output"] = hero_draft_note(model, selected_item) + return True + if key in {"d", "D"}: + if not queue: + state["message"] = "no mission item" + state["output"] = ["No selected mission queue item to dry-run."] + return True + action = queue[int(state.get("selected", 0))] + state["message"] = "dry-running " + str(action.get("title") or "selected item") + state["output"] = run_hero_action(action, dry_run=True) return True if key in {"a", "A", "\r", "\n"}: - action = HERO_QUEUE[int(state.get("selected", 0))] - state["message"] = "running " + action["title"] - state["output"] = run_action(action) + if not queue: + state["message"] = "no mission item" + state["output"] = ["No selected mission queue item to run."] + return True + action = queue[int(state.get("selected", 0))] + state["message"] = "running " + str(action.get("title") or "selected item") + state["output"] = run_hero_action(action) return True if key == "?": state["message"] = "palette" - state["output"] = [ - "j/k or arrows: move", - "1-5: direct select", - "a/enter: run selected", - "o: context", - "u: draft update", - "r: refresh", - "q: quit pane", - ] + state["output"] = hero_help_lines(model) return True return True @@ -1288,49 +1432,92 @@ def activity_row(item: dict[str, Any], width: int) -> str: return f"{age} {severity} {sources} {message}" +def mission_context_lines(model: dict[str, Any], state: dict[str, Any], width: int, compact: bool) -> list[str]: + context = model.get("context") or {} + rows: list[str] = [] + ordered = [ + ("CHANGE RADAR", "change_radar"), + ("ANTI-STALL", "anti_stall"), + ("BLAST RADIUS", "blast_radius"), + ("BLOCKER WATCH", "blocker_watch"), + ("SESSION HEAT", "session_heat"), + ] + max_pairs = 4 if compact else len(ordered) + for label, key in ordered[:max_pairs]: + value = str(context.get(key) or "n/a") + rows.append(styled(label, AMBER, bold=True)) + rows.append(styled(clip_text(value, max(12, width - 2)), TEXT if key != "blocker_watch" else WHITE)) + output = [str(line) for line in (state.get("output") or []) if str(line).strip()] + if output: + rows.append(styled("SELECTED CONTEXT", AMBER, bold=True)) + for line in output[: max(3, 7 if not compact else 4)]: + rows.append(styled(clip_text(line, max(12, width - 2)), WHITE)) + return rows + + +def mission_hub_lines(model: dict[str, Any], width: int, very_compact: bool) -> list[str]: + items = [item for item in (model.get("hub") or []) if isinstance(item, dict)] + if not items: + return [styled("No key bindings configured.", MUTED)] + columns = 3 if width < 100 else 4 + col_width = max(18, (width - (columns - 1) * 2) // columns) + rows: list[str] = [] + for row_start in range(0, len(items), columns): + row = items[row_start:row_start + columns] + titles = [] + details = [] + for item in row: + key = str(item.get("key") or "") + label = str(item.get("label") or "") + detail = str(item.get("detail") or "") + titles.append(ansi_cell(f"{badge(key)} {styled(label, AMBER, bold=True)}", col_width)) + if not very_compact: + details.append(ansi_cell(styled(" " + clip_text(detail, max(8, col_width - 4)), WHITE), col_width)) + rows.append(" ".join(titles)) + if details: + rows.append(" ".join(details)) + return rows + + def render_hero() -> None: columns, lines = term_size() - compact = lines < 56 - very_compact = lines < 44 + compact = lines < 52 + very_compact = lines < 42 width = max(64, min(columns - 2, 120)) content = width - 4 now = datetime.now().strftime("%H:%M:%S") - try: - payload = load_jobs() - jobs = payload.get("jobs", []) - except Exception: - jobs = [] - active_jobs = sum( - 1 - for job in jobs - if str(job.get("status") or "").lower() not in {"succeeded", "failed", "done", "completed"} - ) - active_jobs = active_jobs or min(7, max(1, len(jobs))) - selected = int(HERO_STATE.get("selected", 0)) - selected_action = HERO_QUEUE[selected] + model = build_mission_model() + stats = model.get("stats") or {} + queue = hero_queue(model) + selected = clamp_hero_selection(HERO_STATE, queue) + selected_action = queue[selected] if queue else None top_left = styled(f"{now} hero", MUTED) - top_right = styled(f"JOBS {active_jobs} ACTIVE • ACTIONS {HERO_READY_ACTIONS} READY", AMBER, bold=True) - print(ansi_cell(top_left, content - visible_len(top_right) - 1) + " " + top_right) - brand = styled("> INDUSTRIAL OPS v1.1.0", ORANGE, bold=True) + raw_top_right = ( + f"REV {stats.get('review', 0)} BLK {stats.get('blocked', 0)} " + f"Q {stats.get('queued', 0)} RUN {stats.get('runs', 0)} " + f"MAN {stats.get('manual', 0)} CL {stats.get('cluster', 0)} ACT {stats.get('actions', 0)}" + ) + top_right = styled(clip_text(raw_top_right, max(10, content - visible_len(top_left) - 1)), AMBER, bold=True) + print(ansi_cell(top_left, max(1, content - visible_len(top_right) - 1)) + " " + top_right) + brand = styled("> CENTO INDUSTRIAL OS v1.2.0", ORANGE, bold=True) print(brand) print(f"{ORANGE}{'─' * width}{RESET}") - headline = styled("MISSION CONTROL // CENTRAL ACTION PANE", AMBER, bold=True) - subtitle = styled("not a dashboard - an action router for the whole cockpit", WHITE) + headline = styled("MISSION CONTROL // CENTO MISSION ROUTER", AMBER, bold=True) + subtitle = styled("live Cento tools, Taskstream, agent runs, cluster, git, jobs, and registered actions", WHITE) print(headline) print(subtitle) print() + brief = model.get("brief") or {} mission_body: list[str] = [] - mission_body.extend(mission_row("OBJECTIVE", "Ship central pane that turns intent → task → action.", content - 2)) - mission_body.extend(mission_row("NEXT ACTION", "Wire selected queue item to an executable command.", content - 2, GREEN)) + mission_body.extend(mission_row("OBJECTIVE", str(brief.get("objective") or "Read Cento state."), content - 2)) + mission_body.extend(mission_row("NEXT ACTION", str(brief.get("next_action") or "No active mission item."), content - 2, GREEN)) if not compact: - mission_body.extend(mission_row("PROJECT", "Cento Industrial Cockpit / Bubble Tea TUI", content - 2)) - mission_body.extend(mission_row("DEADLINE", "Today 18:00 • demo-ready by EOD", content - 2, AMBER)) - mission_body.extend(mission_row("SUCCESS", "3 real keybinds, 1 screenshot, 1 follow-up narrative.", content - 2)) - mission_body.extend(mission_row("WHY NOW", "Make the cockpit useful for 100+ engineers, not just pretty.", content - 2)) + mission_body.extend(mission_row("PROJECT", str(brief.get("project") or "Cento"), content - 2)) + mission_body.extend(mission_row("UPDATED", str(model.get("updated_at") or "unknown"), content - 2)) mission_body.append("") - mission_body.append(badge("CONFIDENCE 72%") + styled(" • RISK: pane looks cool but does not reduce clicks", TEXT)) + mission_body.append(badge("LIVE MODEL") + styled(" RISK: " + str(brief.get("risk") or "unknown"), TEXT)) print("\n".join(hero_section("MISSION BRIEF", mission_body, width, "LIVE", "▫"))) print() @@ -1339,29 +1526,31 @@ def render_hero() -> None: right_width = width - left_width - 2 if side_by_side else width left_body_width = max(24, left_width - 5) queue_lines: list[str] = [] - for index, action in enumerate(HERO_QUEUE): + if not queue: + queue_lines.extend( + [ + styled("No active mission items.", WHITE, bold=True), + styled("Taskstream, active runs, cluster, git, jobs, and registered actions did not yield queued work.", MUTED), + ] + ) + max_queue_items = min(len(queue), 4 if very_compact else (6 if compact else 9)) + queue_start = 0 + if queue and len(queue) > max_queue_items: + queue_start = max(0, min(selected - max_queue_items // 2, len(queue) - max_queue_items)) + if queue_start > 0: + queue_lines.append(styled(f" +{queue_start} earlier mission item(s)", MUTED)) + visible_queue = queue[queue_start:queue_start + max_queue_items] + for offset, action in enumerate(visible_queue): + index = queue_start + offset item_lines = queue_item_lines(action, index, left_body_width, index == selected, compact) - if index == len(HERO_QUEUE) - 1 and not compact: + if offset == len(visible_queue) - 1 and not compact: item_lines = item_lines[:-1] queue_lines.extend(item_lines) + remaining_queue = len(queue) - queue_start - len(visible_queue) + if remaining_queue > 0: + queue_lines.append(styled(f" +{remaining_queue} more mission item(s)", MUTED)) - context_lines = [ - styled("CHANGE RADAR", AMBER, bold=True), - styled("2 files changed since last green", TEXT), - styled("ANTI-STALL", AMBER, bold=True), - styled("same failed cmd seen 3x", TEXT), - styled("BLAST RADIUS", AMBER, bold=True), - styled("cluster pane + docs + PR linked", TEXT), - styled("BLOCKER WATCH", AMBER, bold=True), - styled("no owner on docs review", TEXT), - styled("SESSION HEAT", ORANGE, bold=True), - styled("▂▃▂▃▄▃▄▅▄▆▅▇▆", ORANGE, bold=True), - ] - if not compact: - context_lines[6:6] = [ - styled("NARRATIVE", AMBER, bold=True), - styled("demo update can be generated", TEXT), - ] + context_lines = mission_context_lines(model, HERO_STATE, max(20, right_width - 5 if side_by_side else width - 5), compact) queue_box = hero_box("ACTIVE WORK QUEUE", queue_lines, left_width, "SELECTABLE", "▦") detail_box = hero_box("CONTEXT ENGINE", context_lines, right_width, "HOT", "⚡") @@ -1374,35 +1563,11 @@ def render_hero() -> None: print("\n".join(detail_box)) print() - col_width = max(20, (width - 10) // 3) - hub_items = [ - ("j", "JOBS", "focus job queue + show last exit code"), - ("c", "CLUSTER", "jump to cluster health / owners / blast radius"), - ("a", "ACT", "run selected next action"), - ("r", "REFRESH", "rebuild state from jobs, git, TODOs"), - ("o", "OPEN", "launch context pack: docs, PR, logs"), - ("n", "CAPTURE", "turn current line / clipboard into a task"), - ("b", "BLOCK", "file blocker + draft owner ask"), - ("u", "UPDATE", "generate EOD / demo follow-up from session"), - ("?", "PALETTE", "type any cockpit command"), - ] - hub_lines: list[str] = [] - for row_start in range(0, len(hub_items), 3): - row = hub_items[row_start:row_start + 3] - titles = [] - details = [] - for key, label, detail in row: - titles.append(ansi_cell(f"{badge(key)} {styled(label, AMBER, bold=True)}", col_width)) - if not very_compact: - details.append(ansi_cell(styled(" " + clip_text(detail, col_width - 4), WHITE), col_width)) - hub_lines.append(" ".join(titles)) - if details: - hub_lines.append(" ".join(details)) - if row_start < 6 and not compact: - hub_lines.append("") + hub_lines = mission_hub_lines(model, width - 4, very_compact) print("\n".join(hero_box("KEYBOARD / ACTION HUB", hub_lines, width, "GLOBAL", "⌘"))) - selected_line = styled("SELECTED › " + selected_action["title"], AMBER, bold=True) - action_line = styled(f"ACTION › {HERO_STATE.get('message', 'ready')} | press [a] run [o] context [u] update", TEXT) + selected_title = str((selected_action or {}).get("title") or "No mission item selected") + selected_line = styled("SELECTED › " + selected_title, AMBER, bold=True) + action_line = styled(f"ACTION › {HERO_STATE.get('message', 'ready')} | [a] run [d] dry-run [o] context [u] note", TEXT) selected_width = min(content, visible_len(selected_line) + 2) print() print(f"{ORANGE}{'─' * width}{RESET}") diff --git a/scripts/industrial_panel_e2e.sh b/scripts/industrial_panel_e2e.sh index 921f0a6..e074529 100755 --- a/scripts/industrial_panel_e2e.sh +++ b/scripts/industrial_panel_e2e.sh @@ -8,10 +8,12 @@ ROOT_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) render_hero() { local columns=$1 local lines=$2 + local mission_fixture=${3:-} cd "$ROOT_DIR" COLUMNS=$columns \ LINES=$lines \ CENTO_INDUSTRIAL_HERO_BACKGROUND=1 \ + CENTO_INDUSTRIAL_MISSION_FIXTURE=${mission_fixture:+$mission_fixture} \ PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \ python3 "$SCRIPT_DIR/industrial_panel.py" hero --once --plain } @@ -74,24 +76,44 @@ for number, line in enumerate(payload, 1): PY } -output=$(render_hero 120 48) +output=$(render_hero 120 48 "$SCRIPT_DIR/fixtures/industrial_panel/mission-busy.json") assert_widths 120 <<<"$output" -grep -Fq 'MISSION CONTROL // CENTRAL ACTION PANE' <<<"$output" -grep -Fq 'not a dashboard - an action router for the whole cockpit' <<<"$output" +grep -Fq 'MISSION CONTROL // CENTO MISSION ROUTER' <<<"$output" +grep -Fq 'live Cento tools, Taskstream, agent runs, cluster, git, jobs, and registered actions' <<<"$output" grep -Fq 'MISSION BRIEF' <<<"$output" grep -Fq 'ACTIVE WORK QUEUE' <<<"$output" grep -Fq 'CONTEXT ENGINE' <<<"$output" grep -Fq 'KEYBOARD / ACTION HUB' <<<"$output" -grep -Fq 'ACTIONS 12 READY' <<<"$output" -grep -Fq 'ACTION › implement action router' <<<"$output" +grep -Fq 'REV 2 BLK 1 Q 1' <<<"$output" +grep -Fq 'Review ready #101' <<<"$output" +grep -Fq 'Review gate #102' <<<"$output" +grep -Fq 'Blocked #103' <<<"$output" +grep -Fq 'Dispatch dry-run #104' <<<"$output" +grep -Fq 'Manual codex shell' <<<"$output" +grep -Fq 'ACTION › ready' <<<"$output" +grep -Fq 'DRY RUN' <<<"$output" +grep -Fq 'CONTEXT' <<<"$output" + +if grep -Fq 'Finish industrial dashboard' <<<"$output"; then + printf 'industrial panel e2e failed: hero rendered fake queue item\n' >&2 + exit 1 +fi +if grep -Fq 'ACTIONS 12 READY' <<<"$output"; then + printf 'industrial panel e2e failed: hero rendered fake action count\n' >&2 + exit 1 +fi +if grep -Fq 'CAPTURE' <<<"$output" || grep -Fq 'BLOCK ' <<<"$output"; then + printf 'industrial panel e2e failed: hero advertised unimplemented hub keys\n' >&2 + exit 1 +fi if grep -Fq '▀' <<<"$output"; then printf 'industrial panel e2e failed: hero rendered image blocks\n' >&2 exit 1 fi -compact_output=$(render_hero 92 38) +compact_output=$(render_hero 92 38 "$SCRIPT_DIR/fixtures/industrial_panel/mission-busy.json") assert_widths 92 <<<"$compact_output" compact_lines=$(wc -l <<<"$compact_output") if (( compact_lines > 38 )); then @@ -102,6 +124,11 @@ grep -Fq 'ACTIVE WORK QUEUE' <<<"$compact_output" grep -Fq 'CONTEXT ENGINE' <<<"$compact_output" grep -Fq 'KEYBOARD / ACTION HUB' <<<"$compact_output" +clean_output=$(render_hero 100 40 "$SCRIPT_DIR/fixtures/industrial_panel/mission-clean.json") +assert_widths 100 <<<"$clean_output" +grep -Fq 'No active mission items.' <<<"$clean_output" +grep -Fq 'low: board and cluster are quiet' <<<"$clean_output" + empty_cluster_output=$(render_cluster 100 40 "$SCRIPT_DIR/fixtures/industrial_panel/cluster-empty.json") assert_widths 100 <<<"$empty_cluster_output" grep -Fq 'EMPTY' <<<"$empty_cluster_output" @@ -144,6 +171,7 @@ grep -Fq 'IDLE Cluster status' <<<"$actions_output" grep -Fq 'No action has run yet.' <<<"$actions_output" grep -Fq 'Controls:' <<<"$actions_output" python3 "$SCRIPT_DIR/industrial_panel_actions_contract_check.py" +python3 "$SCRIPT_DIR/industrial_mission_contract_check.py" empty_actions_output=$(render_actions 96 28 "$SCRIPT_DIR/fixtures/industrial_panel/empty_actions.json") grep -Fq 'No actions configured.' <<<"$empty_actions_output" diff --git a/scripts/industrial_pet_contract_check.py b/scripts/industrial_pet_contract_check.py new file mode 100755 index 0000000..5783264 --- /dev/null +++ b/scripts/industrial_pet_contract_check.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +import unicodedata +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") +FIXED_NOW = datetime(2026, 5, 12, 14, 0, 0, tzinfo=timezone.utc) + + +def run(cmd: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=ROOT, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + + +def write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def assert_true(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def display_width(value: str) -> int: + width = 0 + for char in ANSI_RE.sub("", value).rstrip("\n"): + if unicodedata.combining(char): + continue + width += 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1 + return width + + +def strip_ansi(value: str) -> str: + return ANSI_RE.sub("", value) + + +def fixture_database(path: Path) -> None: + source = json.loads((ROOT / "data" / "industrial-pet.json").read_text(encoding="utf-8")) + source["rare_events"] = [] + write_json(path, source) + + +def base_env(tmp: Path, database: Path) -> dict[str, str]: + env = dict(os.environ) + env["XDG_STATE_HOME"] = str(tmp / "xdg-state") + env["CENTO_INDUSTRIAL_PET_NOW"] = FIXED_NOW.isoformat().replace("+00:00", "Z") + env["CENTO_INDUSTRIAL_PET_DATABASE"] = str(database) + return env + + +def check_first_render(tmp: Path, database: Path) -> None: + state = tmp / "pet.json" + result = run( + [ + "./scripts/industrial_pet_tui.sh", + "--once", + "--state", + str(state), + "--database", + str(database), + "--width", + "88", + "--height", + "24", + ], + base_env(tmp, database), + ) + assert_true(result.returncode == 0, result.stderr or result.stdout) + upper = result.stdout.upper() + assert_true("DARTH LOLIPOPUS" in upper, "render missing pet header") + assert_true("SNACK" in upper and "REST" in upper and "MENACE" in upper and "LOYAL" in upper, "render missing stats") + assert_true("SITH SNACK" in upper and "TINY MISSION" in upper, "render missing activities") + assert_true("J/K SELECT" in upper and "ENTER PERFORM" in upper, "render missing controls") + assert_true("▀" in result.stdout, "render missing terminal portrait pixels") + assert_true("JOBS DASHBOARD" not in upper, "pet render leaked jobs dashboard text") + assert_true(not state.exists(), "--once should not create the default temp state") + + +def check_action_state_scope(tmp: Path, database: Path) -> None: + state = tmp / "custom" / "darth.json" + env = base_env(tmp, database) + result = run( + ["./scripts/industrial_pet_tui.sh", "--action", "nap", "--state", str(state), "--database", str(database)], + env, + ) + assert_true(result.returncode == 0, result.stderr or result.stdout) + assert_true(state.exists(), "action did not write the explicit state file") + default_state = Path(env["XDG_STATE_HOME"]) / "cento" / "industrial-os" / "darth-lolipopus.json" + assert_true(not default_state.exists(), "action wrote the default XDG state despite --state override") + payload = json.loads(state.read_text(encoding="utf-8")) + assert_true(payload["selected"] == "nap", "state did not record selected action") + assert_true(payload["stats"]["energy"] > 68, "nap did not raise energy") + assert_true(payload["activity_log"][0]["activity"] == "Nap", "activity log missing nap entry") + + +def check_decay_is_deterministic(tmp: Path, database: Path) -> None: + state = tmp / "decay.json" + previous = FIXED_NOW - timedelta(hours=10) + write_json( + state, + { + "name": "Darth Lolipopus", + "created_at": (FIXED_NOW - timedelta(days=2)).isoformat().replace("+00:00", "Z"), + "last_seen": previous.isoformat().replace("+00:00", "Z"), + "updated_at": previous.isoformat().replace("+00:00", "Z"), + "stats": {"snack": 100, "energy": 100, "menace": 100, "affection": 100}, + "selected": "tiny_mission", + "latest_comment": "waiting", + "mood": "smug", + "activity_log": [], + "action_count": 0, + }, + ) + result = run( + ["./scripts/industrial_pet_tui.sh", "--action", "tiny_mission", "--state", str(state), "--database", str(database)], + base_env(tmp, database), + ) + assert_true(result.returncode == 0, result.stderr or result.stdout) + payload = json.loads(state.read_text(encoding="utf-8")) + assert_true(payload["stats"]["snack"] == 54, f"unexpected snack decay: {payload['stats']}") + assert_true(payload["stats"]["energy"] == 70, f"unexpected energy decay: {payload['stats']}") + assert_true(payload["stats"]["menace"] == 100, f"unexpected menace clamp: {payload['stats']}") + assert_true(payload["stats"]["affection"] == 96, f"unexpected affection decay: {payload['stats']}") + assert_true(payload["last_seen"] == FIXED_NOW.isoformat().replace("+00:00", "Z"), "last_seen was not updated deterministically") + + +def check_recovery(tmp: Path, database: Path) -> None: + env = base_env(tmp, database) + for name, content in {"empty": "", "corrupt": "{not-json\n"}.items(): + state = tmp / f"{name}.json" + state.write_text(content, encoding="utf-8") + result = run( + ["./scripts/industrial_pet_tui.sh", "--once", "--state", str(state), "--database", str(database)], + env, + ) + assert_true(result.returncode == 0, result.stderr or result.stdout) + assert_true("DARTH LOLIPOPUS" in result.stdout.upper(), f"{name} state did not recover to pet render") + + +def check_narrow_width(tmp: Path, database: Path) -> None: + result = run( + [ + "./scripts/industrial_pet_tui.sh", + "--once", + "--state", + str(tmp / "narrow.json"), + "--database", + str(database), + "--width", + "48", + "--height", + "18", + ], + base_env(tmp, database), + ) + assert_true(result.returncode == 0, result.stderr or result.stdout) + too_wide = [(number, display_width(line), line) for number, line in enumerate(result.stdout.splitlines(), start=1) if display_width(line) > 48] + assert_true(not too_wide, f"narrow render overflowed: {too_wide[:3]}") + + +def check_slot_portrait_layout(tmp: Path, database: Path) -> None: + result = run( + [ + "./scripts/industrial_pet_tui.sh", + "--once", + "--portrait", + "slot", + "--state", + str(tmp / "slot.json"), + "--database", + str(database), + "--width", + "98", + "--height", + "24", + ], + base_env(tmp, database), + ) + assert_true(result.returncode == 0, result.stderr or result.stdout) + assert_true("▀" not in result.stdout, "slot render should not draw terminal-pixel portrait blocks") + plain_lines = [strip_ansi(line) for line in result.stdout.splitlines()] + too_wide = [(number, display_width(line), line) for number, line in enumerate(result.stdout.splitlines(), start=1) if display_width(line) > 98] + assert_true(not too_wide, f"slot render overflowed: {too_wide[:3]}") + interesting = [line for line in plain_lines if "DARTH LOLIPOPUS" in line or "ACTIVITIES" in line or "SNACK" in line] + assert_true(interesting, "slot render missing expected right-column content") + assert_true(all(line.startswith(" " * 20) for line in interesting[:3]), "slot render did not reserve the left image column") + + +def check_slot_activities_fit(tmp: Path, database: Path) -> None: + result = run( + [ + "./scripts/industrial_pet_tui.sh", + "--once", + "--portrait", + "slot", + "--state", + str(tmp / "slot-fit.json"), + "--database", + str(database), + "--width", + "82", + "--height", + "24", + ], + base_env(tmp, database), + ) + assert_true(result.returncode == 0, result.stderr or result.stdout) + plain_lines = [strip_ansi(line) for line in result.stdout.splitlines()] + start = next((index for index, line in enumerate(plain_lines) if "ACTIVITIES" in line), None) + assert_true(start is not None, "slot render missing activities section") + activity_lines = [] + for line in plain_lines[start + 1 :]: + if not line.strip(): + break + activity_lines.append(line) + assert_true(len(activity_lines) >= 6, "slot render missing activity rows") + assert_true(not any("..." in line for line in activity_lines), f"slot activity row clipped: {activity_lines}") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="cento-industrial-pet-") as tmp_name: + tmp = Path(tmp_name) + database = tmp / "industrial-pet.json" + fixture_database(database) + check_first_render(tmp, database) + check_action_state_scope(tmp, database) + check_decay_is_deterministic(tmp, database) + check_recovery(tmp, database) + check_narrow_width(tmp, database) + check_slot_portrait_layout(tmp, database) + check_slot_activities_fit(tmp, database) + print("industrial pet contract check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/industrial_pet_tui.go b/scripts/industrial_pet_tui.go new file mode 100644 index 0000000..37614fe --- /dev/null +++ b/scripts/industrial_pet_tui.go @@ -0,0 +1,1102 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "hash/fnv" + "image" + "image/color" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type tickMsg time.Time + +type petDatabase struct { + SchemaVersion int `json:"schema_version"` + Activities []petActivity `json:"activities"` + MoodComments map[string][]string `json:"mood_comments"` + IdleBarks []string `json:"idle_barks"` + RareEvents []rareEvent `json:"rare_events"` +} + +type petActivity struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Deltas map[string]int `json:"deltas"` + Comments []string `json:"comments"` + Log []string `json:"log"` +} + +type rareEvent struct { + ID string `json:"id"` + OneIn int `json:"one_in"` + Text string `json:"text"` + Deltas map[string]int `json:"deltas"` + Comment string `json:"comment"` +} + +type petState struct { + Name string `json:"name"` + CreatedAt string `json:"created_at"` + LastSeen string `json:"last_seen"` + UpdatedAt string `json:"updated_at"` + Stats map[string]int `json:"stats"` + Selected string `json:"selected"` + LatestComment string `json:"latest_comment"` + Mood string `json:"mood"` + ActivityLog []petLogEntry `json:"activity_log"` + ActionCount int `json:"action_count"` +} + +type petLogEntry struct { + At string `json:"at"` + Activity string `json:"activity"` + Comment string `json:"comment"` + Deltas map[string]int `json:"deltas,omitempty"` +} + +type loadResult struct { + State petState + Recovered bool + Decayed bool + PreviousLastSeen time.Time +} + +type statDef struct { + Key string + Label string +} + +type model struct { + statePath string + databasePath string + imagePath string + portraitMode string + db petDatabase + state petState + now time.Time + lastSeen time.Time + portrait []string + portraitW int + portraitRows int + selected int + width int + height int + interval time.Duration + err error + saveErr error +} + +var ( + statDefs = []statDef{ + {Key: "snack", Label: "SNACK"}, + {Key: "energy", Label: "REST"}, + {Key: "menace", Label: "MENACE"}, + {Key: "affection", Label: "LOYAL"}, + } + + red = lipgloss.Color("#FF4B00") + pink = lipgloss.Color("#FF8ACB") + amber = lipgloss.Color("#FFB000") + green = lipgloss.Color("#7CFB8B") + text = lipgloss.Color("#F4E8DC") + muted = lipgloss.Color("#8B746F") + dark = lipgloss.Color("#080503") + panelStyle = lipgloss.NewStyle().Foreground(text).Padding(1, 1) + titleStyle = lipgloss.NewStyle().Foreground(pink).Bold(true) + nameStyle = lipgloss.NewStyle().Foreground(text) + mutedStyle = lipgloss.NewStyle().Foreground(muted) + ruleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7A2E3C")) + valueStyle = lipgloss.NewStyle().Foreground(amber).Bold(true) + badStyle = lipgloss.NewStyle().Foreground(red).Bold(true) + goodStyle = lipgloss.NewStyle().Foreground(green).Bold(true) + badgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFF4F8")).Background(lipgloss.Color("#5B1735")).Bold(true).Padding(0, 1) +) + +func initRoot() string { + if root := os.Getenv("CENTO_ROOT_DIR"); root != "" { + return root + } + if cwd, err := os.Getwd(); err == nil { + if _, err := os.Stat(filepath.Join(cwd, "data", "tools.json")); err == nil { + return cwd + } + } + return "." +} + +func defaultStatePath() string { + if explicit := os.Getenv("CENTO_INDUSTRIAL_PET_STATE"); explicit != "" { + return explicit + } + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + home, err := os.UserHomeDir() + if err != nil || home == "" { + stateHome = "." + } else { + stateHome = filepath.Join(home, ".local", "state") + } + } + return filepath.Join(stateHome, "cento", "industrial-os", "darth-lolipopus.json") +} + +func defaultDatabasePath(root string) string { + if explicit := os.Getenv("CENTO_INDUSTRIAL_PET_DATABASE"); explicit != "" { + return explicit + } + return filepath.Join(root, "data", "industrial-pet.json") +} + +func defaultImagePath(root string) string { + if explicit := os.Getenv("CENTO_INDUSTRIAL_PET_IMAGE"); explicit != "" { + return explicit + } + candidates := []string{ + filepath.Join(root, "assets", "industrial-os", "darth-lolipopus.png"), + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + candidates = append(candidates, + filepath.Join(home, ".config", "polybar", "scripts", "rofi", "rainbow-reaper.png"), + filepath.Join(home, ".config", "polybar2", "scripts", "rofi", "rainbow-reaper.png"), + ) + } + for _, path := range candidates { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + return path + } + } + return "" +} + +func parseNow(value string) (time.Time, error) { + if value == "" { + value = os.Getenv("CENTO_INDUSTRIAL_PET_NOW") + } + if value == "" { + return time.Now().UTC(), nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, err + } + return parsed.UTC(), nil +} + +func loadDatabase(path string) (petDatabase, error) { + raw, err := os.ReadFile(path) + if err != nil { + return petDatabase{}, err + } + var db petDatabase + if err := json.Unmarshal(raw, &db); err != nil { + return petDatabase{}, err + } + if len(db.Activities) == 0 { + return petDatabase{}, errors.New("pet database has no activities") + } + seen := map[string]bool{} + for _, activity := range db.Activities { + if activity.ID == "" || activity.Name == "" { + return petDatabase{}, errors.New("pet database activity is missing id or name") + } + if seen[activity.ID] { + return petDatabase{}, fmt.Errorf("duplicate activity id: %s", activity.ID) + } + seen[activity.ID] = true + } + return db, nil +} + +func defaultState(now time.Time) petState { + stamp := now.Format(time.RFC3339) + return petState{ + Name: "Darth Lolipopus", + CreatedAt: stamp, + LastSeen: stamp, + UpdatedAt: stamp, + Stats: map[string]int{ + "snack": 72, + "energy": 68, + "menace": 54, + "affection": 61, + }, + Selected: "sith_snack", + LatestComment: "Darth Lolipopus adjusts a tiny cape and demands appropriate awe.", + Mood: "scheming", + ActivityLog: []petLogEntry{ + {At: stamp, Activity: "summon", Comment: "Cute Sith nursery online."}, + }, + } +} + +func loadState(path string, now time.Time) loadResult { + raw, err := os.ReadFile(path) + if err != nil || len(strings.TrimSpace(string(raw))) == 0 { + state := defaultState(now) + return loadResult{State: state, Recovered: true, PreviousLastSeen: now} + } + + var state petState + if err := json.Unmarshal(raw, &state); err != nil { + state := defaultState(now) + state.LatestComment = "The nursery holocron rebooted Darth Lolipopus cleanly." + return loadResult{State: state, Recovered: true, PreviousLastSeen: now} + } + + recovered := normalizeState(&state, now) + previousLastSeen, ok := parseStamp(state.LastSeen) + if !ok { + previousLastSeen = now + recovered = true + } + decayed := applyDecay(&state, previousLastSeen, now) + state.Mood = computeMood(state.Stats) + if decayed { + state.LastSeen = now.Format(time.RFC3339) + state.UpdatedAt = now.Format(time.RFC3339) + } + return loadResult{State: state, Recovered: recovered, Decayed: decayed, PreviousLastSeen: previousLastSeen} +} + +func normalizeState(state *petState, now time.Time) bool { + recovered := false + stamp := now.Format(time.RFC3339) + if strings.TrimSpace(state.Name) == "" { + state.Name = "Darth Lolipopus" + recovered = true + } + if _, ok := parseStamp(state.CreatedAt); !ok { + state.CreatedAt = stamp + recovered = true + } + if _, ok := parseStamp(state.LastSeen); !ok { + state.LastSeen = stamp + recovered = true + } + if _, ok := parseStamp(state.UpdatedAt); !ok { + state.UpdatedAt = stamp + recovered = true + } + if state.Stats == nil { + state.Stats = map[string]int{} + recovered = true + } + defaults := defaultState(now).Stats + for _, stat := range statDefs { + value, ok := state.Stats[stat.Key] + if !ok { + state.Stats[stat.Key] = defaults[stat.Key] + recovered = true + continue + } + state.Stats[stat.Key] = clamp(value, 0, 100) + } + if state.Selected == "" { + state.Selected = "sith_snack" + recovered = true + } + if state.LatestComment == "" { + state.LatestComment = "Darth Lolipopus watches the cockpit with suspicious sweetness." + recovered = true + } + if state.Mood == "" { + state.Mood = computeMood(state.Stats) + recovered = true + } + if state.ActivityLog == nil { + state.ActivityLog = []petLogEntry{} + recovered = true + } + if len(state.ActivityLog) > 8 { + state.ActivityLog = state.ActivityLog[:8] + recovered = true + } + return recovered +} + +func parseStamp(value string) (time.Time, bool) { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, false + } + return parsed.UTC(), true +} + +func applyDecay(state *petState, previousLastSeen time.Time, now time.Time) bool { + if now.Before(previousLastSeen) { + return false + } + hours := int(now.Sub(previousLastSeen).Hours()) + if hours <= 0 { + return false + } + units := min(hours, 72) + applyDeltas(state.Stats, map[string]int{ + "snack": -4 * units, + "energy": -2 * units, + "menace": -1 * units, + "affection": -1 * units, + }) + if units >= 6 { + state.LatestComment = "Darth Lolipopus waited in the shadows and now expects tribute." + } + return true +} + +func saveState(path string, state petState) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(raw, '\n'), 0o644) +} + +func (m model) Init() tea.Cmd { + return tickCmd(m.interval) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "r": + m.reload(time.Now().UTC(), true) + case "j", "down": + if m.selected < len(m.db.Activities)-1 { + m.selected++ + m.state.Selected = m.db.Activities[m.selected].ID + } + case "k", "up": + if m.selected > 0 { + m.selected-- + m.state.Selected = m.db.Activities[m.selected].ID + } + case "enter": + m.performSelected(time.Now().UTC()) + case "1", "2", "3", "4", "5", "6": + index := int(msg.String()[0] - '1') + if index >= 0 && index < len(m.db.Activities) { + m.selected = index + m.state.Selected = m.db.Activities[index].ID + m.performSelected(time.Now().UTC()) + } + } + case tickMsg: + m.reload(time.Time(msg), true) + return m, tickCmd(m.interval) + } + return m, nil +} + +func (m *model) reload(now time.Time, persist bool) { + result := loadState(m.statePath, now.UTC()) + m.state = result.State + m.now = now.UTC() + m.lastSeen = result.PreviousLastSeen + m.selected = selectedIndex(m.db, m.state.Selected) + if persist && (result.Recovered || result.Decayed) { + m.saveErr = saveState(m.statePath, m.state) + } +} + +func (m *model) performSelected(now time.Time) { + if len(m.db.Activities) == 0 { + m.err = errors.New("pet database has no activities") + return + } + if m.selected < 0 || m.selected >= len(m.db.Activities) { + m.selected = 0 + } + activity := m.db.Activities[m.selected] + m.now = now.UTC() + performActivity(&m.state, m.db, activity.ID, m.now) + m.lastSeen = m.now + m.saveErr = saveState(m.statePath, m.state) +} + +func (m model) View() tea.View { + width := m.width + if width <= 0 { + width = 50 + } + width = clamp(width-2, 34, 110) + body := m.renderBody(width - 2) + view := tea.NewView(panelStyle.Width(width).Render(body)) + view.AltScreen = true + return view +} + +func (m model) renderBody(width int) string { + width = clamp(width, 34, 110) + now := m.now + if now.IsZero() { + now = time.Now().UTC() + } + mood := computeMood(m.state.Stats) + comment := m.state.LatestComment + if comment == "" { + comment = choose(m.db.IdleBarks, "idle"+now.Format(time.RFC3339)) + } + age := ageLabel(m.state.CreatedAt, now) + lastSeen := agoLabel(m.lastSeen, now) + if m.lastSeen.IsZero() { + lastSeen = "new" + } + + if m.portraitMode == "slot" && width >= 68 { + return lipgloss.JoinVertical(lipgloss.Left, m.slotBody(mood, age, lastSeen, comment, width, now)...) + } + lines := []string{ + mutedStyle.Render(clip(now.Local().Format("15:04:05")+" pet", width)), + titleStyle.Render("> DARTH LOLIPOPUS"), + ruleStyle.Render(strings.Repeat("-", width)), + } + if (len(m.portrait) > 0 || m.portraitMode == "slot") && width >= 68 { + lines = append(lines, m.heroBlock(mood, age, lastSeen, width), "") + } else { + if len(m.portrait) > 0 { + lines = append(lines, m.portrait...) + } + lines = append(lines, avatarLine(mood, age, lastSeen, width), "") + for _, stat := range statDefs { + lines = append(lines, statLine(stat.Label, m.state.Stats[stat.Key], width)) + } + } + lines = append(lines, "", valueStyle.Render("ACTIVITIES")) + compact := m.height > 0 && m.height <= 26 + for index, activity := range m.db.Activities { + if index >= 6 { + break + } + prefix := " " + if index == m.selected { + prefix = ">" + } + row := fmt.Sprintf("%s %d %-16s %s", prefix, index+1, activity.Name, deltaSummary(activity.Deltas)) + row = clip(row, width) + if index == m.selected { + lines = append(lines, goodStyle.Render(row)) + } else { + lines = append(lines, nameStyle.Render(row)) + } + } + + selected := m.selectedActivity() + if compact { + lines = append(lines, + "", + valueStyle.Render("SELECTED")+" "+nameStyle.Render(clip(selected.Name, max(8, width-9))), + valueStyle.Render("COMMENT")+" "+nameStyle.Render(clip(comment, max(8, width-8))), + ) + if len(m.state.ActivityLog) > 0 { + lines = append(lines, valueStyle.Render("LOG")+" "+mutedStyle.Render(clip(m.state.ActivityLog[0].Comment, max(8, width-4)))) + } + lines = append(lines, mutedStyle.Render(clip("j/k select | 1-6 act | enter perform | r refresh | q quit", width))) + return lipgloss.JoinVertical(lipgloss.Left, lines...) + } + lines = append(lines, + "", + valueStyle.Render("SELECTED")+" "+nameStyle.Render(clip(selected.Name, max(8, width-9))), + mutedStyle.Render(clip(selected.Description, width)), + "", + valueStyle.Render("COMMENT"), + nameStyle.Render(clip(comment, width)), + ) + if m.err != nil { + lines = append(lines, badStyle.Render(clip("error: "+m.err.Error(), width))) + } + if m.saveErr != nil { + lines = append(lines, badStyle.Render(clip("save failed: "+m.saveErr.Error(), width))) + } + if m.err == nil && len(m.db.Activities) == 0 { + lines = append(lines, badStyle.Render("database has no activities")) + } + + logLines := m.logLines(width) + lines = append(lines, "", valueStyle.Render("LOG")) + lines = append(lines, logLines...) + lines = append(lines, "", mutedStyle.Render(clip("j/k select | 1-6 act | enter perform | r refresh | q quit", width))) + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m model) slotBody(mood, age, lastSeen, comment string, width int, now time.Time) []string { + leftW := max(22, m.portraitW) + rightW := max(34, width-leftW-3) + lines := []string{ + slotLine(leftW, mutedStyle.Render(clip(now.Local().Format("15:04:05")+" pet", rightW))), + slotLine(leftW, titleStyle.Render("> DARTH LOLIPOPUS")), + slotLine(leftW, ruleStyle.Render(strings.Repeat("-", rightW))), + slotLine(leftW, avatarLine(mood, age, lastSeen, rightW)), + slotLine(leftW, ""), + } + for _, stat := range statDefs { + lines = append(lines, slotLine(leftW, statLine(stat.Label, m.state.Stats[stat.Key], rightW))) + } + lines = append(lines, slotLine(leftW, ""), slotLine(leftW, valueStyle.Render("ACTIVITIES"))) + for index, activity := range m.db.Activities { + if index >= 6 { + break + } + prefix := " " + if index == m.selected { + prefix = ">" + } + row := activityRow(prefix, index+1, activity, rightW) + row = clip(row, rightW) + if index == m.selected { + row = goodStyle.Render(row) + } else { + row = nameStyle.Render(row) + } + lines = append(lines, slotLine(leftW, row)) + } + selected := m.selectedActivity() + lines = append(lines, + slotLine(leftW, ""), + slotLine(leftW, valueStyle.Render("SELECTED")+" "+nameStyle.Render(clip(selected.Name, max(8, rightW-9)))), + slotLine(leftW, valueStyle.Render("COMMENT")+" "+nameStyle.Render(clip(comment, max(8, rightW-8)))), + ) + if len(m.state.ActivityLog) > 0 { + lines = append(lines, slotLine(leftW, valueStyle.Render("LOG")+" "+mutedStyle.Render(clip(m.state.ActivityLog[0].Comment, max(8, rightW-4))))) + } + lines = append(lines, slotLine(leftW, mutedStyle.Render(clip("j/k select | 1-6 act | enter perform | r refresh | q quit", rightW)))) + return lines +} + +func slotLine(leftWidth int, right string) string { + return strings.Repeat(" ", leftWidth) + " " + right +} + +func activityRow(prefix string, index int, activity petActivity, width int) string { + nameWidth := clamp(width-34, 12, 16) + deltaBudget := max(0, width-5-nameWidth) + name := padRight(clip(activity.Name, nameWidth), nameWidth) + return fmt.Sprintf("%s %d %s %s", prefix, index, name, deltaSummaryForWidth(activity.Deltas, deltaBudget)) +} + +func padRight(value string, width int) string { + padding := width - lipgloss.Width(value) + if padding <= 0 { + return value + } + return value + strings.Repeat(" ", padding) +} + +func (m model) heroBlock(mood, age, lastSeen string, width int) string { + portraitW := m.portraitW + if portraitW <= 0 { + portraitW = 22 + } + rightW := max(34, width-portraitW-3) + right := []string{ + avatarLine(mood, age, lastSeen, rightW), + "", + } + for _, stat := range statDefs { + right = append(right, statLine(stat.Label, m.state.Stats[stat.Key], rightW)) + } + lines := []string{} + count := max(max(len(m.portrait), m.portraitRows), len(right)) + for index := 0; index < count; index++ { + left := strings.Repeat(" ", portraitW) + if index < len(m.portrait) { + left = m.portrait[index] + } + rhs := "" + if index < len(right) { + rhs = right[index] + } + lines = append(lines, left+" "+rhs) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func avatarLine(mood, age, lastSeen string, width int) string { + badge := badgeStyle.Render(strings.ToUpper(mood)) + left := " /-.-\\ " + meta := fmt.Sprintf("age %s last %s", age, lastSeen) + plainBudget := max(4, width-lipgloss.Width(left)-lipgloss.Width(badge)-2) + return left + badge + " " + mutedStyle.Render(clip(meta, plainBudget)) +} + +func (m model) selectedActivity() petActivity { + if len(m.db.Activities) == 0 { + return petActivity{Name: "none", Description: "No pet activities are loaded."} + } + index := m.selected + if index < 0 || index >= len(m.db.Activities) { + index = 0 + } + return m.db.Activities[index] +} + +func (m model) logLines(width int) []string { + limit := 4 + if m.height > 0 && m.height < 22 { + limit = 2 + } + if len(m.state.ActivityLog) == 0 { + return []string{mutedStyle.Render("no activity yet")} + } + lines := []string{} + for index, entry := range m.state.ActivityLog { + if index >= limit { + break + } + when := "now" + if parsed, ok := parseStamp(entry.At); ok { + when = parsed.Local().Format("15:04") + } + label := entry.Activity + if label == "" { + label = "activity" + } + lines = append(lines, mutedStyle.Render(clip(fmt.Sprintf("%s %-15s %s", when, label, entry.Comment), width))) + } + return lines +} + +func statLine(label string, value int, width int) string { + value = clamp(value, 0, 100) + barWidth := clamp(width-18, 8, 24) + filled := (value*barWidth + 50) / 100 + empty := barWidth - filled + style := goodStyle + if value < 30 { + style = badStyle + } else if value < 55 { + style = valueStyle + } + bar := style.Render(strings.Repeat("#", filled)) + mutedStyle.Render(strings.Repeat("-", empty)) + line := fmt.Sprintf("%-6s [%s] %3d", label, bar, value) + if lipgloss.Width(line) > width { + return clip(line, width) + } + return line +} + +func buildPortrait(path string, cols int, rows int) ([]string, int) { + if path == "" || cols <= 0 || rows <= 0 { + return nil, 0 + } + file, err := os.Open(path) + if err != nil { + return nil, 0 + } + defer file.Close() + img, _, err := image.Decode(file) + if err != nil { + return nil, 0 + } + bounds := img.Bounds() + if bounds.Dx() <= 0 || bounds.Dy() <= 0 { + return nil, 0 + } + targetH := rows * 2 + lines := make([]string, 0, rows) + for row := 0; row < rows; row++ { + var builder strings.Builder + for col := 0; col < cols; col++ { + top := sampleImage(img, bounds, col, row*2, cols, targetH) + bottom := sampleImage(img, bounds, col, row*2+1, cols, targetH) + tr, tg, tb := rgb(top) + br, bg, bb := rgb(bottom) + builder.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀\x1b[0m", tr, tg, tb, br, bg, bb)) + } + lines = append(lines, builder.String()) + } + return lines, cols +} + +func sampleImage(img image.Image, bounds image.Rectangle, x int, y int, width int, height int) color.Color { + var srcX, srcY int + if width <= 1 { + srcX = bounds.Min.X + bounds.Dx()/2 + } else { + srcX = bounds.Min.X + x*(bounds.Dx()-1)/(width-1) + } + if height <= 1 { + srcY = bounds.Min.Y + bounds.Dy()/2 + } else { + srcY = bounds.Min.Y + y*(bounds.Dy()-1)/(height-1) + } + return img.At(srcX, srcY) +} + +func rgb(value color.Color) (uint32, uint32, uint32) { + r, g, b, a := value.RGBA() + if a == 0 { + return 5, 4, 3 + } + return r >> 8, g >> 8, b >> 8 +} + +func performActivity(state *petState, db petDatabase, activityID string, now time.Time) error { + activity, ok := activityByID(db, activityID) + if !ok { + return fmt.Errorf("unknown activity: %s", activityID) + } + applyDeltas(state.Stats, activity.Deltas) + state.ActionCount++ + state.Selected = activity.ID + state.Mood = computeMood(state.Stats) + comment := choose(activity.Comments, activity.ID+now.Format(time.RFC3339)+fmt.Sprint(state.ActionCount)) + if comment == "" { + comment = choose(db.MoodComments[state.Mood], state.Mood+fmt.Sprint(state.ActionCount)) + } + logText := choose(activity.Log, "log"+activity.ID+fmt.Sprint(state.ActionCount)) + if logText == "" { + logText = comment + } + if rare := chooseRareEvent(db.RareEvents, now, state.ActionCount, activity.ID); rare != nil { + applyDeltas(state.Stats, rare.Deltas) + if rare.Comment != "" { + comment = rare.Comment + } + if rare.Text != "" { + logText = rare.Text + } + state.Mood = computeMood(state.Stats) + } + state.LatestComment = comment + stamp := now.UTC().Format(time.RFC3339) + state.LastSeen = stamp + state.UpdatedAt = stamp + entry := petLogEntry{At: stamp, Activity: activity.Name, Comment: logText, Deltas: activity.Deltas} + state.ActivityLog = append([]petLogEntry{entry}, state.ActivityLog...) + if len(state.ActivityLog) > 8 { + state.ActivityLog = state.ActivityLog[:8] + } + return nil +} + +func activityByID(db petDatabase, id string) (petActivity, bool) { + for _, activity := range db.Activities { + if activity.ID == id { + return activity, true + } + } + return petActivity{}, false +} + +func selectedIndex(db petDatabase, id string) int { + for index, activity := range db.Activities { + if activity.ID == id { + return index + } + } + return 0 +} + +func resolveActivityID(db petDatabase, value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + if len(value) == 1 && value[0] >= '1' && value[0] <= '6' { + index := int(value[0] - '1') + if index < len(db.Activities) { + return db.Activities[index].ID, nil + } + } + if _, ok := activityByID(db, value); ok { + return value, nil + } + return "", fmt.Errorf("unknown activity: %s", value) +} + +func chooseRareEvent(events []rareEvent, now time.Time, count int, activityID string) *rareEvent { + for index := range events { + event := events[index] + if event.OneIn <= 0 { + continue + } + seed := fmt.Sprintf("%s:%d:%s:%s", event.ID, count, activityID, now.Format(time.RFC3339)) + if int(hashString(seed)%uint32(event.OneIn)) == 0 { + return &events[index] + } + } + return nil +} + +func applyDeltas(stats map[string]int, deltas map[string]int) { + if stats == nil { + return + } + for key, delta := range deltas { + stats[key] = clamp(stats[key]+delta, 0, 100) + } +} + +func computeMood(stats map[string]int) string { + if stats == nil { + return "mysterious" + } + switch { + case stats["snack"] < 25: + return "hangry" + case stats["energy"] < 25: + return "sleepy" + case stats["affection"] < 30: + return "sulking" + case stats["menace"] > 82: + return "dramatic" + case stats["snack"] > 72 && stats["energy"] > 65: + return "smug" + default: + return "scheming" + } +} + +func deltaSummary(deltas map[string]int) string { + return deltaSummaryWithLabels(deltas, []string{"SNACK", "REST", "MENACE", "LOYAL"}) +} + +func deltaSummaryForWidth(deltas map[string]int, width int) string { + for _, labels := range [][]string{ + {"SNACK", "REST", "MENACE", "LOYAL"}, + {"SNK", "RST", "MEN", "LOY"}, + {"S", "R", "M", "L"}, + } { + summary := deltaSummaryWithLabels(deltas, labels) + if lipgloss.Width(summary) <= width { + return summary + } + } + return clip(deltaSummaryWithLabels(deltas, []string{"S", "R", "M", "L"}), width) +} + +func deltaSummaryWithLabels(deltas map[string]int, labels []string) string { + parts := []string{} + for index, stat := range statDefs { + value := deltas[stat.Key] + if value == 0 { + continue + } + sign := "+" + if value < 0 { + sign = "" + } + label := stat.Label + if index < len(labels) { + label = labels[index] + } + parts = append(parts, fmt.Sprintf("%s%d %s", sign, value, label)) + } + return strings.Join(parts, " ") +} + +func choose(values []string, seed string) string { + if len(values) == 0 { + return "" + } + index := int(hashString(seed) % uint32(len(values))) + return values[index] +} + +func hashString(value string) uint32 { + h := fnv.New32a() + _, _ = h.Write([]byte(value)) + return h.Sum32() +} + +func ageLabel(createdAt string, now time.Time) string { + created, ok := parseStamp(createdAt) + if !ok { + return "new" + } + return durationLabel(now.Sub(created)) +} + +func agoLabel(stamp time.Time, now time.Time) string { + if stamp.IsZero() { + return "new" + } + return durationLabel(now.Sub(stamp)) +} + +func durationLabel(duration time.Duration) string { + if duration < 0 { + duration = 0 + } + minutes := int(duration.Minutes()) + switch { + case minutes < 1: + return "now" + case minutes < 60: + return fmt.Sprintf("%dm", minutes) + case minutes < 48*60: + return fmt.Sprintf("%dh", minutes/60) + default: + return fmt.Sprintf("%dd", minutes/(24*60)) + } +} + +func clip(value string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(value) <= width { + return value + } + runes := []rune(value) + if width <= 3 { + if len(runes) <= width { + return value + } + return string(runes[:width]) + } + for lipgloss.Width(string(runes)) > width-3 && len(runes) > 0 { + runes = runes[:len(runes)-1] + } + return string(runes) + "..." +} + +func clamp(value, low, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func isTTY() bool { + info, err := os.Stdout.Stat() + return err == nil && (info.Mode()&os.ModeCharDevice) != 0 +} + +func tickCmd(interval time.Duration) tea.Cmd { + return tea.Tick(interval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func main() { + root := initRoot() + fs := flag.NewFlagSet("industrial-pet-tui", flag.ExitOnError) + once := fs.Bool("once", false, "render once and exit") + action := fs.String("action", "", "perform one activity id and save state") + statePath := fs.String("state", defaultStatePath(), "pet state path") + databasePath := fs.String("database", defaultDatabasePath(root), "pet interaction database path") + imagePath := fs.String("image", defaultImagePath(root), "pet portrait image path") + portraitMode := fs.String("portrait", "ansi", "portrait mode: ansi, slot, or none") + reset := fs.Bool("reset", false, "reset Darth Lolipopus state") + width := fs.Int("width", 98, "render width for --once") + height := fs.Int("height", 24, "render height for --once") + nowFlag := fs.String("now", "", "RFC3339 time override for deterministic validation") + interval := fs.Duration("interval", 20*time.Second, "refresh interval") + fs.Parse(os.Args[1:]) + + now, err := parseNow(*nowFlag) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + db, dbErr := loadDatabase(*databasePath) + + var result loadResult + if *reset { + state := defaultState(now) + result = loadResult{State: state, Recovered: true, PreviousLastSeen: now} + } else { + result = loadState(*statePath, now) + } + + model := model{ + statePath: *statePath, + databasePath: *databasePath, + imagePath: *imagePath, + portraitMode: *portraitMode, + db: db, + state: result.State, + now: now, + lastSeen: result.PreviousLastSeen, + selected: selectedIndex(db, result.State.Selected), + width: *width, + height: *height, + interval: *interval, + err: dbErr, + } + switch *portraitMode { + case "ansi": + model.portrait, model.portraitW = buildPortrait(model.imagePath, 18, 8) + model.portraitRows = len(model.portrait) + case "slot": + model.portraitW = 26 + model.portraitRows = 14 + case "none": + default: + fmt.Fprintln(os.Stderr, "portrait mode must be ansi, slot, or none") + os.Exit(2) + } + + shouldSave := *reset || result.Recovered || result.Decayed + if dbErr == nil && *action != "" { + activityID, resolveErr := resolveActivityID(db, *action) + if resolveErr != nil { + fmt.Fprintln(os.Stderr, resolveErr) + os.Exit(2) + } + model.selected = selectedIndex(db, activityID) + model.state.Selected = activityID + performActivity(&model.state, db, activityID, now) + model.lastSeen = now + shouldSave = true + } + if shouldSave && (!*once || *action != "" || *reset) { + model.saveErr = saveState(*statePath, model.state) + } + + if *once || !isTTY() || *action != "" { + fmt.Println(model.renderBody(clamp(*width, 34, 110))) + if model.err != nil || model.saveErr != nil { + os.Exit(1) + } + return + } + + if shouldSave { + model.saveErr = saveState(*statePath, model.state) + } + program := tea.NewProgram(model) + if _, err := program.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/industrial_pet_tui.sh b/scripts/industrial_pet_tui.sh new file mode 100755 index 0000000..301ce4e --- /dev/null +++ b/scripts/industrial_pet_tui.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +source "$SCRIPT_DIR/lib/common.sh" + +ROOT_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +CACHE_DIR="$ROOT_DIR/workspace/tmp/bin" +BINARY="$CACHE_DIR/cento-industrial-pet-tui" +SOURCE_FILE="$ROOT_DIR/scripts/industrial_pet_tui.go" +DATABASE_FILE="$ROOT_DIR/data/industrial-pet.json" +IMAGE_FILE="$ROOT_DIR/assets/industrial-os/darth-lolipopus.png" +PANE_IMAGE_FILE="$ROOT_DIR/assets/industrial-os/darth-lolipopus-pane.png" +GO_MOD="$ROOT_DIR/go.mod" +GO_SUM="$ROOT_DIR/go.sum" + +cento_require_cmd go +cento_ensure_dir "$CACHE_DIR" +export CENTO_ROOT_DIR="$ROOT_DIR" +unset NO_COLOR +export CLICOLOR=1 +export CLICOLOR_FORCE=1 +export COLORTERM="${COLORTERM:-truecolor}" + +if [[ ! -x "$BINARY" || "$SOURCE_FILE" -nt "$BINARY" || "$DATABASE_FILE" -nt "$BINARY" || ( -f "$IMAGE_FILE" && "$IMAGE_FILE" -nt "$BINARY" ) || ( -f "$PANE_IMAGE_FILE" && "$PANE_IMAGE_FILE" -nt "$BINARY" ) || "$GO_MOD" -nt "$BINARY" || ( -f "$GO_SUM" && "$GO_SUM" -nt "$BINARY" ) ]]; then + (cd -- "$ROOT_DIR" && go build -o "$BINARY" ./scripts/industrial_pet_tui.go) +fi + +exec "$BINARY" "$@" diff --git a/scripts/industrial_workspace.sh b/scripts/industrial_workspace.sh index d233c3b..d180b70 100755 --- a/scripts/industrial_workspace.sh +++ b/scripts/industrial_workspace.sh @@ -15,6 +15,7 @@ PANEL_SCRIPT="$ROOT_DIR/scripts/industrial_panel.py" HERO_ART="${CENTO_INDUSTRIAL_HERO_ART:-$ROOT_DIR/assets/industrial-os/volcano-pane.png}" TERMINAL_ART="${CENTO_INDUSTRIAL_TERMINAL_ART:-$ROOT_DIR/assets/industrial-os/activity-pane.png}" JOBS_ART="${CENTO_INDUSTRIAL_JOBS_ART:-$ROOT_DIR/assets/industrial-os/jobs-pane.png}" +PET_PANE_ART="${CENTO_INDUSTRIAL_PET_PANE_ART:-$ROOT_DIR/assets/industrial-os/darth-lolipopus-pane.png}" CLUSTER_ART="${CENTO_INDUSTRIAL_CLUSTER_ART:-$ROOT_DIR/assets/industrial-os/cluster-pane.png}" ACTIVITY_ART="${CENTO_INDUSTRIAL_ACTIVITY_ART:-$ROOT_DIR/assets/industrial-os/activity-pane.png}" AGENTS_ART="${CENTO_INDUSTRIAL_AGENTS_ART:-$ACTIVITY_ART}" @@ -67,7 +68,7 @@ KITTY_TERMINAL_OPTIONS=( GENERATED_CLASSES=( "cento-industrial-hero" "cento-industrial-terminal" - "cento-industrial-jobs" + "cento-industrial-pet" "cento-industrial-cluster" "cento-industrial-agents" "cento-industrial-actions" @@ -222,11 +223,13 @@ placeholder_names = { "industrial hero", "terminal", "jobs dashboard", + "darth lolipopus", "cluster status", "system resources", "activity feed", "agent runs", "quick actions", + "mozilla vpn", } @@ -308,6 +311,7 @@ panel_art() { hero) printf '%s\n' "$HERO_ART" ;; terminal) printf '%s\n' "$TERMINAL_ART" ;; jobs) printf '%s\n' "$JOBS_ART" ;; + pet) printf '%s\n' "$PET_PANE_ART" ;; cluster) printf '%s\n' "$CLUSTER_ART" ;; activity) printf '%s\n' "$ACTIVITY_ART" ;; agents) printf '%s\n' "$AGENTS_ART" ;; @@ -368,16 +372,24 @@ launch_panel() { ) append_background_options "$panel" "0.90" command=(env CENTO_INDUSTRIAL_HERO_BACKGROUND=1 python3 "$PANEL_SCRIPT" "$panel") - elif [[ "$panel" == "jobs" ]]; then - append_background_options "$panel" "0.92" - command=("$ROOT_DIR/scripts/industrial_jobs_tui.sh") + elif [[ "$panel" == "pet" ]]; then + if backgrounds_enabled && [[ -f "$PET_PANE_ART" ]]; then + append_background_options "$panel" "0.00" + command=("$ROOT_DIR/scripts/industrial_pet_tui.sh" "--portrait" "slot") + else + append_solid_background_options + command=("$ROOT_DIR/scripts/industrial_pet_tui.sh") + fi elif [[ "$panel" == "cluster" ]]; then append_background_options "$panel" "0.90" command=("$ROOT_DIR/scripts/industrial_cluster_tui.sh") elif [[ "$panel" == "agents" ]]; then append_background_options "$panel" "0.92" command=("$ROOT_DIR/scripts/industrial_aux_tui.sh" "$panel") - elif [[ "$panel" == "activity" || "$panel" == "actions" ]]; then + elif [[ "$panel" == "actions" ]]; then + append_background_options "$panel" "0.92" + command=("$ROOT_DIR/scripts/mozilla_vpn_tui.sh") + elif [[ "$panel" == "activity" ]]; then append_background_options "$panel" "0.92" command=("$ROOT_DIR/scripts/industrial_aux_tui.sh" "$panel") fi @@ -418,10 +430,10 @@ ensure_all_windows() { ensure_discord launch_panel "cento-industrial-hero" "industrial os" "hero" launch_terminal - launch_panel "cento-industrial-jobs" "jobs dashboard" "jobs" + launch_panel "cento-industrial-pet" "darth lolipopus" "pet" launch_panel "cento-industrial-cluster" "cluster status" "cluster" launch_panel "cento-industrial-agents" "agent runs" "agents" - launch_panel "cento-industrial-actions" "quick actions" "actions" + launch_panel "cento-industrial-actions" "mozilla vpn" "actions" local klass for klass in "discord" "${GENERATED_CLASSES[@]}"; do @@ -498,7 +510,7 @@ row( ) row( [ - "cento-industrial-jobs", + "cento-industrial-pet", "cento-industrial-cluster", "cento-industrial-agents", "cento-industrial-actions", @@ -600,7 +612,7 @@ print(f"cento-industrial-hero\t{hero_x}\t{y}\t{hero_width}\t{top_height}") print(f"cento-industrial-terminal\t{terminal_x}\t{y}\t{terminal_width}\t{top_height}") row( [ - "cento-industrial-jobs", + "cento-industrial-pet", "cento-industrial-cluster", "cento-industrial-agents", "cento-industrial-actions", diff --git a/scripts/jobs_server.py b/scripts/jobs_server.py index 1739df8..4a2c484 100755 --- a/scripts/jobs_server.py +++ b/scripts/jobs_server.py @@ -17,6 +17,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent +EXPLICIT_RUN_ROOT = "CENTO_CLUSTER_JOBS_ROOT" in os.environ RUN_ROOT = Path(os.environ.get("CENTO_CLUSTER_JOBS_ROOT", ROOT_DIR / "workspace" / "runs" / "cluster-jobs")) TEMPLATE_DIR = ROOT_DIR / "templates" / "jobs-web" DEFAULT_HOST = "127.0.0.1" @@ -205,6 +206,303 @@ def task_details(job: dict[str, Any], run_dir: Path) -> list[dict[str, Any]]: return details +def read_json_if_exists(path: Path) -> dict[str, Any]: + if not path.exists() or not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def read_jsonl_tail(path: Path, limit: int = 4) -> list[dict[str, Any]]: + if not path.exists() or not path.is_file(): + return [] + rows: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + rows.append(payload) + return rows[-limit:] + + +def latest_file_time(*paths: Path) -> datetime: + latest: datetime | None = None + for path in paths: + if not path.exists() or not path.is_file(): + continue + stamp = datetime.fromtimestamp(path.stat().st_mtime).astimezone() + if latest is None or stamp > latest: + latest = stamp + return latest or datetime.now().astimezone() + + +def first_existing_path(*paths: Path) -> Path | None: + for path in paths: + if path.exists() and path.is_file(): + return path + return None + + +def int_value(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def first_int(*values: Any) -> int: + for value in values: + parsed = int_value(value) + if parsed: + return parsed + return 0 + + +def progress_label(label: str, done: int, total: int) -> str: + return f"{label} {done}/{total}" if total else f"{label} {done}" + + +def live_task(task_id: str, title: str, status: str, *, node: str = "local", log: Path | None = None) -> dict[str, Any]: + normalized = normalize_status(status) + returncode: int | None + if normalized in {"completed", "succeeded", "success", "done"}: + returncode = 0 + elif normalized in {"failed", "error", "invalid"}: + returncode = 1 + else: + returncode = None + return { + "id": task_id, + "node": node, + "title": title, + "scope": "", + "ownership": [], + "returncode": returncode, + "elapsed_seconds": None, + "log": str(log) if log else "", + "log_exists": bool(log and log.exists()), + "log_tail": recent_log_tail(log, limit=4) if log else [], + "script": "", + "script_exists": False, + "manifest": "", + "manifest_exists": False, + } + + +def autopilot_jobs(now: datetime) -> list[dict[str, Any]]: + root = Path(os.environ.get("CENTO_WALK_AUTOPILOT_ROOT", ROOT_DIR / "workspace" / "runs" / "walk-autopilot")) + if not root.exists() or not root.is_dir(): + return [] + jobs: list[dict[str, Any]] = [] + for run_dir in root.iterdir(): + if not run_dir.is_dir(): + continue + metrics_path = run_dir / "metrics.jsonl" + events_path = run_dir / "events.jsonl" + if not metrics_path.exists() and not events_path.exists(): + continue + metrics = read_jsonl_tail(metrics_path, 1) + events = read_jsonl_tail(events_path, 1) + metric = metrics[-1] if metrics else {} + event = events[-1] if events else {} + config = read_json_if_exists(run_dir / "config.json") + manifest = read_json_if_exists(run_dir / "execution-manifest.json") + updated_at = latest_file_time(metrics_path, events_path, run_dir / "handoff.md", run_dir / "factory_promotion.json") + for payload in (event, metric): + parsed = parse_time(payload.get("written_at")) + if parsed and parsed > updated_at: + updated_at = parsed + status = normalize_status(metric.get("status") or event.get("status")) + event_name = str(event.get("event") or "progress") + event_status = normalize_status(event.get("status")) + degraded_reasons: list[str] = [] + if "failed" in event_status or event_status == "error": + status = "failed" + degraded_reasons.append(f"{event_name}={event_status}") + if event_name == "hard_stop": + status = "failed" + completed_exec = int_value(metric.get("completed_proreq_executions") or event.get("execution_index")) + expected_exec = first_int(manifest.get("proreq_execution_count"), config.get("proreq_execution_count")) + completed_calls = int_value(metric.get("proreq_call_count")) + expected_calls = first_int(manifest.get("expected_proreq_call_count"), config.get("target_proreq_calls"), config.get("min_proreq_calls")) + completed_swarm = int_value(metric.get("patch_swarm_runs")) + expected_swarm = first_int(manifest.get("patch_swarm_milestone_count"), config.get("expected_patch_swarm_runs")) + completed_receipts = int_value(metric.get("candidate_patch_receipts")) + expected_receipts = first_int(manifest.get("expected_candidate_patch_receipts"), config.get("expected_candidate_patch_receipts")) + if event_status and event_status != "unknown": + step = f"{event_name.replace('_', ' ')}: {event_status.replace('-', ' ')}" + elif expected_exec: + step = f"executions {completed_exec}/{expected_exec} · calls {completed_calls}/{expected_calls} · patch swarm {completed_swarm}/{expected_swarm}" + else: + step = event_name.replace("_", " ") + latest_log = first_existing_path(events_path, metrics_path) + summary_path = run_dir / "handoff.md" + summary = { + "id": run_dir.name, + "status": status, + "feature": f"Factory scale autopilot ({config.get('run_mode') or 'walk'})", + "task_count": max(completed_exec, expected_exec), + "result_count": completed_exec, + "failed_task_count": 1 if degraded_reasons else 0, + "summary_exists": summary_path.exists(), + "updated_at": updated_at.isoformat(timespec="seconds"), + "updated_age": age_label(updated_at, now), + "current_step": step, + "latest_log": { + "path": str(latest_log or ""), + "exists": bool(latest_log), + "tail": recent_log_tail(latest_log, limit=4) if latest_log else [], + }, + "state": "degraded" if degraded_reasons or status == "failed" else "ok", + "degraded_reasons": degraded_reasons, + } + jobs.append( + { + "id": run_dir.name, + "source": "walk-autopilot", + "status": status, + "feature": summary["feature"], + "created_at": str(config.get("created_at") or ""), + "finished_at": "", + "updated_at": summary["updated_at"], + "updated_age": summary["updated_age"], + "repo": str(ROOT_DIR), + "run_dir": str(run_dir), + "job": str(run_dir / "config.json"), + "summary": str(summary_path), + "job_summary": summary, + "summary_exists": summary_path.exists(), + "agent_command": f"cento walk-autopilot factory-scale status --run-id {run_dir.name} --json", + "tasks": [ + live_task("proreq", progress_label("ProReq calls", completed_calls, expected_calls), status, log=latest_log), + live_task("executions", progress_label("ProReq executions", completed_exec, expected_exec), status, log=latest_log), + live_task("patch-swarm", progress_label("Patch Swarm runs", completed_swarm, expected_swarm), status, log=latest_log), + live_task("receipts", progress_label("Candidate receipts", completed_receipts, expected_receipts), status, log=latest_log), + ], + } + ) + return jobs + + +def factory_feature(plan: dict[str, Any], queue: dict[str, Any]) -> str: + request = plan.get("request") if isinstance(plan.get("request"), dict) else {} + for value in (request.get("raw"), plan.get("feature"), plan.get("package"), queue.get("package")): + line = first_line(value) + if line: + return line + return "Factory run" + + +def factory_jobs(now: datetime) -> list[dict[str, Any]]: + root = Path(os.environ.get("CENTO_FACTORY_RUNS_ROOT", ROOT_DIR / "workspace" / "runs" / "factory")) + if not root.exists() or not root.is_dir(): + return [] + jobs: list[dict[str, Any]] = [] + for run_dir in root.iterdir(): + if not run_dir.is_dir() or not (run_dir / "factory-plan.json").exists(): + continue + plan = read_json_if_exists(run_dir / "factory-plan.json") + queue = read_json_if_exists(run_dir / "queue" / "state.json") or read_json_if_exists(run_dir / "queue" / "queue.json") + validation = read_json_if_exists(run_dir / "integration" / "validation-fanout.json") + integration = read_json_if_exists(run_dir / "integration" / "integration-state.json") + stats = queue.get("stats") if isinstance(queue.get("stats"), dict) else {} + updated_at = latest_file_time( + run_dir / "factory-plan.json", + run_dir / "summary.md", + run_dir / "queue" / "state.json", + run_dir / "queue" / "events.jsonl", + run_dir / "integration" / "validation-fanout.json", + run_dir / "integration" / "integration-state.json", + ) + for payload in (validation, integration): + for key in ("generated_at", "updated_at"): + parsed = parse_time(payload.get(key)) + if parsed and parsed > updated_at: + updated_at = parsed + total = int_value(stats.get("total")) + tasks_map = queue.get("tasks") if isinstance(queue.get("tasks"), dict) else {} + if not total: + total = len(tasks_map) or len(plan.get("tasks") or []) + status = "queued" if int_value(stats.get("queued")) or int_value(stats.get("waiting")) else "succeeded" + if int_value(stats.get("running")) or int_value(stats.get("leased")) or int_value(stats.get("validating")): + status = "running" + if normalize_status(validation.get("status")) in {"failed", "error", "invalid"}: + status = "failed" + failed = int_value(stats.get("blocked")) + int_value(stats.get("deadletter")) + int_value(validation.get("failed_count")) + if failed: + status = "failed" + if status == "succeeded" and total and not validation and not (int_value(stats.get("done")) + int_value(stats.get("integrated"))): + status = "planned" + reasons: list[str] = [] + if int_value(validation.get("failed_count")): + reasons.append(f"{int_value(validation.get('failed_count'))} validation failure(s)") + if int_value(stats.get("blocked")): + reasons.append(f"{int_value(stats.get('blocked'))} blocked task(s)") + readiness = integration.get("merge_readiness") if isinstance(integration.get("merge_readiness"), dict) else {} + for blocker in readiness.get("blockers") or []: + reasons.append(str(blocker)) + if len(reasons) >= 3: + break + if validation: + step = f"validation fanout {normalize_status(validation.get('status'))} · {int_value(validation.get('passed_count'))} passed / {int_value(validation.get('failed_count'))} failed" + else: + step = f"queued {int_value(stats.get('queued'))} · running {int_value(stats.get('running')) + int_value(stats.get('validating'))} · done {int_value(stats.get('done')) + int_value(stats.get('integrated'))} / {total}" + latest_log = first_existing_path(run_dir / "queue" / "events.jsonl", run_dir / "integration" / "validation-fanout.json") + summary_path = run_dir / "summary.md" + summary = { + "id": run_dir.name, + "status": status, + "feature": factory_feature(plan, queue), + "task_count": total, + "result_count": int_value(stats.get("done")) + int_value(stats.get("integrated")) + int_value(validation.get("passed_count")), + "failed_task_count": failed, + "summary_exists": summary_path.exists(), + "updated_at": updated_at.isoformat(timespec="seconds"), + "updated_age": age_label(updated_at, now), + "current_step": step, + "latest_log": { + "path": str(latest_log or ""), + "exists": bool(latest_log), + "tail": recent_log_tail(latest_log, limit=4) if latest_log else [], + }, + "state": "degraded" if reasons or status == "failed" else ("empty" if total == 0 else "ok"), + "degraded_reasons": reasons, + } + task_rows = [] + for task_id in sorted(tasks_map)[:8]: + task = tasks_map[task_id] if isinstance(tasks_map[task_id], dict) else {} + task_rows.append(live_task(str(task.get("task_id") or task_id), str(task.get("title") or ""), normalize_status(task.get("status")), node=str(task.get("node") or "local"), log=latest_log)) + jobs.append( + { + "id": run_dir.name, + "source": "factory", + "status": status, + "feature": summary["feature"], + "created_at": str(plan.get("created_at") or ""), + "finished_at": "", + "updated_at": summary["updated_at"], + "updated_age": summary["updated_age"], + "repo": str(ROOT_DIR), + "run_dir": str(run_dir), + "job": str(run_dir / "factory-plan.json"), + "summary": str(summary_path), + "job_summary": summary, + "summary_exists": summary_path.exists(), + "agent_command": f"cento factory status {run_dir} --json", + "tasks": task_rows, + } + ) + return jobs + + def load_jobs() -> dict[str, Any]: RUN_ROOT.mkdir(parents=True, exist_ok=True) jobs = [] @@ -250,6 +548,7 @@ def load_jobs() -> dict[str, Any]: jobs.append( { "id": job.get("id", job_path.parent.name), + "source": "cluster-jobs", "status": normalized["status"], "feature": normalized["feature"], "created_at": job.get("created_at", ""), @@ -266,6 +565,18 @@ def load_jobs() -> dict[str, Any]: "tasks": task_details(job, run_dir), } ) + if not EXPLICIT_RUN_ROOT or os.environ.get("CENTO_INDUSTRIAL_JOBS_INCLUDE_LIVE") == "1": + jobs.extend(autopilot_jobs(current_time)) + jobs.extend(factory_jobs(current_time)) + counts = {} + states = {} + for job in jobs: + summary = job.get("job_summary") if isinstance(job.get("job_summary"), dict) else {} + status = normalize_status(summary.get("status") or job.get("status")) + state = str(summary.get("state") or "unknown") + counts[status] = counts.get(status, 0) + 1 + states[state] = states.get(state, 0) + 1 + jobs.sort(key=lambda item: parse_time((item.get("job_summary") or {}).get("updated_at")) or datetime.fromtimestamp(0).astimezone(), reverse=True) return { "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "run_root": str(RUN_ROOT), diff --git a/scripts/mozilla_vpn_tui.go b/scripts/mozilla_vpn_tui.go new file mode 100644 index 0000000..f09c21f --- /dev/null +++ b/scripts/mozilla_vpn_tui.go @@ -0,0 +1,883 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "syscall" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type tickMsg time.Time + +type snapshotMsg struct { + data vpnSnapshot +} + +type commandDoneMsg struct { + label string + output string + err error +} + +type vpnSnapshot struct { + Installed bool + Version string + Status string + DisplayStatus string + Service string + ProxyService string + Processes string + Countries []countryChoice + CheckedAt time.Time + Err string +} + +type countryChoice struct { + Name string + Hostname string + Count int +} + +type serverChoice struct { + Country string + City string + Hostname string +} + +type model struct { + width int + height int + interval time.Duration + data vpnSnapshot + loading bool + running string + output string + selected int +} + +var ( + orange = lipgloss.Color("#FF4B00") + amber = lipgloss.Color("#FF9A3D") + green = lipgloss.Color("#A0D76E") + warn = lipgloss.Color("#FFD166") + text = lipgloss.Color("#FFFFFF") + muted = lipgloss.Color("#DCCFC4") + panel = lipgloss.NewStyle().Foreground(text).Background(lipgloss.Color("#050403")).Padding(1, 1) + title = lipgloss.NewStyle().Foreground(orange).Bold(true) + label = lipgloss.NewStyle().Foreground(amber).Bold(true) + value = lipgloss.NewStyle().Foreground(text) + quiet = lipgloss.NewStyle().Foreground(muted) + goodBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#050403")).Background(green).Bold(true).Padding(0, 1) + warnBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#050403")).Background(warn).Bold(true).Padding(0, 1) + errBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#050403")).Background(orange).Bold(true).Padding(0, 1) + card = lipgloss.NewStyle().Background(lipgloss.Color("#111315")).Padding(0, 1) + urlPat = regexp.MustCompile(`https?://\S+`) +) + +func run(timeout time.Duration, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if ctx.Err() == context.DeadlineExceeded { + return text, fmt.Errorf("%s timed out", name) + } + return text, err +} + +func loadSnapshot() vpnSnapshot { + snap := vpnSnapshot{CheckedAt: time.Now()} + if _, err := exec.LookPath("mozillavpn"); err != nil { + snap.Err = "mozillavpn command not found" + return snap + } + snap.Installed = true + if out, err := run(4*time.Second, "mozillavpn", "--version"); err == nil { + snap.Version = out + } else { + snap.Version = strings.TrimSpace(out) + } + if out, err := run(6*time.Second, "mozillavpn", "status"); err == nil { + snap.Status = out + snap.DisplayStatus = summarizeStatus(out) + } else { + snap.Status = strings.TrimSpace(out) + if snap.Status == "" { + snap.Status = err.Error() + } + snap.DisplayStatus = summarizeStatus(snap.Status) + } + if out, err := run(3*time.Second, "systemctl", "is-active", "mozillavpn.service"); err == nil { + snap.Service = out + } else if strings.TrimSpace(out) != "" { + snap.Service = strings.TrimSpace(out) + } + if out, err := run(3*time.Second, "systemctl", "is-active", "socksproxy.service"); err == nil { + snap.ProxyService = out + } else if strings.TrimSpace(out) != "" { + snap.ProxyService = strings.TrimSpace(out) + } + if out, err := run(3*time.Second, "pgrep", "-a", "-f", "mozillavpn|socksproxy"); err == nil { + snap.Processes = out + } + if snap.Installed && !strings.Contains(strings.ToLower(snap.Status), "not authenticated") { + snap.Countries = loadCountries() + } + return snap +} + +func loadCountries() []countryChoice { + out, _ := run(8*time.Second, "mozillavpn", "servers", "--json", "--cache") + if countries := countriesFromJSON(out); len(countries) > 0 { + return countries + } + out, _ = run(10*time.Second, "mozillavpn", "servers", "--json") + return countriesFromJSON(out) +} + +func loadCmd() tea.Cmd { + return func() tea.Msg { + return snapshotMsg{data: loadSnapshot()} + } +} + +func tickCmd(interval time.Duration) tea.Cmd { + return tea.Tick(interval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func nativeCmd(labelText string, args ...string) tea.Cmd { + return func() tea.Msg { + if len(args) == 0 { + return commandDoneMsg{label: labelText, err: fmt.Errorf("missing mozillavpn command")} + } + if args[0] == "ui" { + out, err := openMozillaVPNUI() + return commandDoneMsg{label: labelText, output: out, err: err} + } + if args[0] == "login" { + out, err := startLoginFlow() + return commandDoneMsg{label: labelText, output: out, err: err} + } + out, err := run(25*time.Second, "mozillavpn", args...) + return commandDoneMsg{label: labelText, output: out, err: err} + } +} + +func selectCountryCmd(choice countryChoice) tea.Cmd { + return func() tea.Msg { + if strings.TrimSpace(choice.Hostname) == "" { + return commandDoneMsg{label: "select country", err: fmt.Errorf("no server hostname for %s", choice.Name)} + } + out, err := run(25*time.Second, "mozillavpn", "select", choice.Hostname) + return commandDoneMsg{label: "select " + choice.Name, output: out, err: err} + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch(loadCmd(), tickCmd(m.interval)) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "j", "J", "down": + if len(m.data.Countries) > 0 { + m.selected = min(m.selected+1, len(m.data.Countries)-1) + } + case "k", "K", "up": + if len(m.data.Countries) > 0 { + m.selected = max(m.selected-1, 0) + } + case "enter", "c", "C": + if len(m.data.Countries) > 0 { + choice := m.data.Countries[m.selected] + m.running = "select " + choice.Name + return m, selectCountryCmd(choice) + } + case "r", "R": + m.loading = true + return m, loadCmd() + case "u", "U": + m.running = "open ui" + return m, nativeCmd("open ui", "ui") + case "l", "L": + m.running = "login" + return m, nativeCmd("login", "login") + case "a", "A": + m.running = "activate" + return m, nativeCmd("activate", "activate") + case "d", "D": + m.running = "deactivate" + return m, nativeCmd("deactivate", "deactivate") + } + case tickMsg: + if m.loading || m.running != "" { + return m, tickCmd(m.interval) + } + m.loading = true + return m, tea.Batch(loadCmd(), tickCmd(m.interval)) + case snapshotMsg: + m.loading = false + m.data = msg.data + if len(m.data.Countries) == 0 { + m.selected = 0 + } else if m.selected >= len(m.data.Countries) { + m.selected = len(m.data.Countries) - 1 + } + case commandDoneMsg: + m.running = "" + if msg.err != nil { + m.output = fmt.Sprintf("%s failed: %s\n%s", msg.label, msg.err, strings.TrimSpace(msg.output)) + } else { + m.output = fmt.Sprintf("%s: %s", msg.label, strings.TrimSpace(msg.output)) + } + return m, loadCmd() + } + return m, nil +} + +func (m model) View() tea.View { + width := m.width + if width <= 0 { + width = 50 + } + width = clamp(width-2, 42, 70) + body := m.render(width - 4) + view := tea.NewView(panel.Width(width).Render(body)) + view.AltScreen = true + return view +} + +func statusBadge(s vpnSnapshot) string { + if !s.Installed || s.Err != "" { + return errBadge.Render("MISSING") + } + lower := strings.ToLower(s.Status) + if strings.Contains(lower, "not authenticated") { + return warnBadge.Render("LOGIN") + } + if strings.Contains(lower, "connected") || strings.Contains(lower, "active") { + return goodBadge.Render("ON") + } + return warnBadge.Render("READY") +} + +func (m model) render(width int) string { + s := m.data + header := title.Render("> MOZILLA VPN") + badge := statusBadge(s) + headerGap := strings.Repeat(" ", max(1, width-lipgloss.Width(header)-lipgloss.Width(badge))) + lines := []string{ + header + headerGap + badge, + row("VERSION", emptyFallback(s.Version, "unknown"), width), + row("DAEMON", emptyFallback(s.Service, "unknown"), width), + row("PROXY", emptyFallback(s.ProxyService, "unknown"), width), + row("CHECKED", s.CheckedAt.Format("15:04:05"), width), + "", + label.Render("STATUS"), + card.Width(width).Render(wrap(emptyFallback(s.DisplayStatus, s.Err), width-2)), + } + if m.loading { + lines = append(lines, "", quiet.Render("refreshing...")) + } + if m.running != "" { + lines = append(lines, "", quiet.Render("running: "+m.running)) + } + if strings.TrimSpace(m.output) != "" { + lines = append(lines, "", label.Render("LAST"), card.Width(width).Render(wrap(m.output, width-2))) + } + lines = append(lines, + "", + label.Render("COUNTRIES"), + m.renderCountries(width), + "", + label.Render("ACTIONS"), + actionLine("u", "open ui", "l", "login", width), + actionLine("a", "activate", "d", "deactivate", width), + actionLine("j/k", "country", "c", "choose", width), + actionLine("r", "refresh", "q", "quit", width), + ) + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func row(k, v string, width int) string { + key := label.Width(10).Render(k) + return key + " " + value.Render(truncate(v, max(8, width-12))) +} + +func (m model) renderCountries(width int) string { + if len(m.data.Countries) == 0 { + return quiet.Render(wrap("Login, then refresh to load countries from Mozilla VPN servers.", width)) + } + start := max(0, m.selected-2) + if start+5 > len(m.data.Countries) { + start = max(0, len(m.data.Countries)-5) + } + var lines []string + for i := start; i < min(len(m.data.Countries), start+5); i++ { + choice := m.data.Countries[i] + cursor := " " + style := quiet + if i == m.selected { + cursor = ">" + style = value + } + line := fmt.Sprintf("%s %-26s %2d", cursor, truncate(choice.Name, 26), choice.Count) + lines = append(lines, style.Render(truncate(line, width))) + } + return strings.Join(lines, "\n") +} + +func actionLine(k1, v1, k2, v2 string, width int) string { + left := fmt.Sprintf("%s %s", label.Render(k1), value.Render(v1)) + right := fmt.Sprintf("%s %s", label.Render(k2), value.Render(v2)) + gap := strings.Repeat(" ", max(2, width-lipgloss.Width(left)-lipgloss.Width(right))) + return left + gap + right +} + +func emptyFallback(valueText, fallback string) string { + if strings.TrimSpace(valueText) == "" { + return fallback + } + return strings.TrimSpace(valueText) +} + +func wrap(s string, width int) string { + width = max(12, width) + words := strings.Fields(strings.ReplaceAll(s, "\n", " ")) + if len(words) == 0 { + return "" + } + var lines []string + line := "" + for _, word := range words { + if lipgloss.Width(line)+1+lipgloss.Width(word) > width && line != "" { + lines = append(lines, line) + line = word + } else if line == "" { + line = word + } else { + line += " " + word + } + } + if line != "" { + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +func truncate(s string, width int) string { + s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ") + if lipgloss.Width(s) <= width { + return s + } + if width <= 3 { + return s[:max(0, width)] + } + runes := []rune(s) + out := "" + for _, r := range runes { + if lipgloss.Width(out)+lipgloss.Width(string(r))+1 > width { + break + } + out += string(r) + } + return out + "..." +} + +func firstLines(s string, limit int) string { + var lines []string + for _, line := range strings.Split(strings.TrimSpace(s), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lines = append(lines, line) + if len(lines) >= limit { + break + } + } + return strings.Join(lines, "\n") +} + +func processLines(s string, width int) string { + var lines []string + for _, line := range strings.Split(firstLines(s, 3), "\n") { + lines = append(lines, truncate(line, width)) + } + return strings.Join(lines, "\n") +} + +func summarizeStatus(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + fields := map[string]string{} + for _, line := range strings.Split(raw, "\n") { + key, valueText, ok := strings.Cut(line, ":") + if !ok { + continue + } + fields[strings.TrimSpace(key)] = strings.TrimSpace(valueText) + } + auth := fields["User status"] + if auth == "" { + return raw + } + parts := []string{"auth: " + auth} + if vpnState := fields["VPN state"]; vpnState != "" { + parts = append(parts, "vpn: "+vpnState) + } + if country := fields["Server country"]; country != "" { + if city := fields["Server city"]; city != "" { + country += " / " + city + } + parts = append(parts, "server: "+country) + } + if devices := fields["Active devices"]; devices != "" { + parts = append(parts, "devices: "+devices) + } + return strings.Join(parts, "\n") +} + +func openMozillaVPNUI() (string, error) { + if out, err := run(2*time.Second, "pgrep", "-f", "^/usr/bin/mozillavpn ui$"); err == nil && strings.TrimSpace(out) != "" { + go focusMozillaVPNWindow() + return "focused existing native Mozilla VPN app", nil + } + candidates := [][]string{ + {"gtk-launch", "org.mozilla.vpn"}, + {"gio", "launch", "/usr/share/applications/org.mozilla.vpn.desktop"}, + {"mozillavpn", "ui"}, + } + for _, candidate := range candidates { + if _, err := exec.LookPath(candidate[0]); err != nil { + continue + } + if err := startDetached(filepath.Join(os.TempDir(), "cento-mozilla-vpn-ui.log"), candidate[0], candidate[1:]...); err != nil { + return "", err + } + go focusMozillaVPNWindow() + return "launched native Mozilla VPN app via " + strings.Join(candidate, " "), nil + } + return "", fmt.Errorf("no launcher found for Mozilla VPN UI") +} + +func startLoginFlow() (string, error) { + if status, err := run(4*time.Second, "mozillavpn", "status"); err == nil { + lower := strings.ToLower(status) + if strings.Contains(lower, "user status: authenticated") && !strings.Contains(lower, "not authenticated") { + out, focusErr := openMozillaVPNUI() + if focusErr != nil { + return "already authenticated", focusErr + } + return "already authenticated\n" + out, nil + } + } + logPath := filepath.Join(os.TempDir(), "cento-mozilla-vpn-login.log") + logFile, err := os.Create(logPath) + if err != nil { + return "", err + } + cmd := exec.Command("mozillavpn", "login") + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + stdout, err := cmd.StdoutPipe() + if err != nil { + logFile.Close() + return "", err + } + stderr, err := cmd.StderrPipe() + if err != nil { + logFile.Close() + return "", err + } + if err := cmd.Start(); err != nil { + logFile.Close() + return "", err + } + + lines := make(chan string, 32) + done := make(chan error, 1) + var mu sync.Mutex + writeLine := func(line string) { + mu.Lock() + defer mu.Unlock() + fmt.Fprintln(logFile, line) + } + scan := func(reader io.Reader) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + writeLine(line) + select { + case lines <- line: + default: + } + } + } + go scan(stdout) + go scan(stderr) + go func() { + err := cmd.Wait() + writeLine(fmt.Sprintf("[mozillavpn login exited: %v]", err)) + mu.Lock() + logFile.Close() + mu.Unlock() + done <- err + }() + + var seen []string + timer := time.NewTimer(10 * time.Second) + defer timer.Stop() + for { + select { + case line := <-lines: + seen = append(seen, line) + if url := urlPat.FindString(line); url != "" { + url = strings.TrimRight(url, ".,)") + if err := openURL(url); err != nil { + return fmt.Sprintf("auth URL ready\n%s\nlog: %s", url, logPath), err + } + return fmt.Sprintf("opened auth URL in browser\nlog: %s", logPath), nil + } + case err := <-done: + text := strings.Join(seen, "\n") + if text == "" { + text = "mozillavpn login exited without an auth URL" + } + if err != nil { + return text + "\nlog: " + logPath, err + } + return text + "\nlog: " + logPath, nil + case <-timer.C: + tail := strings.Join(lastN(seen, 3), "\n") + if tail == "" { + tail = "waiting for auth URL" + } + return "login process started\n" + tail + "\nlog: " + logPath, nil + } + } +} + +func openURL(url string) error { + candidates := [][]string{ + {"xdg-open", url}, + {"sensible-browser", url}, + {"firefox", url}, + } + for _, candidate := range candidates { + if _, err := exec.LookPath(candidate[0]); err != nil { + continue + } + return startDetached(filepath.Join(os.TempDir(), "cento-mozilla-vpn-open-url.log"), candidate[0], candidate[1:]...) + } + return fmt.Errorf("no browser launcher found") +} + +func startDetached(logPath string, name string, args ...string) error { + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + cmd := exec.Command(name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Stdout = logFile + cmd.Stderr = logFile + if err := cmd.Start(); err != nil { + logFile.Close() + return err + } + go func() { + _ = cmd.Wait() + _ = logFile.Close() + }() + return nil +} + +func focusMozillaVPNWindow() { + time.Sleep(900 * time.Millisecond) + _, _ = run(2*time.Second, "i3-msg", `[class="(?i)^Mozilla VPN$"] focus`) +} + +func lastN(lines []string, n int) []string { + if len(lines) <= n { + return lines + } + return lines[len(lines)-n:] +} + +func countriesFromJSON(raw string) []countryChoice { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var payload any + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return nil + } + var servers []serverChoice + collectServers(payload, serverChoice{}, &servers) + return groupCountries(servers) +} + +func collectServers(valueAny any, inherited serverChoice, out *[]serverChoice) { + switch value := valueAny.(type) { + case []any: + for _, item := range value { + collectServers(item, inherited, out) + } + case map[string]any: + current := inherited + hasCities := hasAnyKey(value, "cities", "city") + hasServers := hasAnyKey(value, "servers") + if text := firstString(value, "country", "countryName", "country_name", "countryCode", "country_code"); text != "" { + current.Country = text + } + if text := firstString(value, "city", "cityName", "city_name"); text != "" { + current.City = text + } + if text := firstString(value, "name"); text != "" { + if hasCities || hasAnyKey(value, "countryCode", "country_code") { + current.Country = text + } else if hasServers || current.Country != "" { + current.City = text + } + } + if host := firstString(value, "hostname", "hostName", "host_name", "serverHostname", "server_hostname", "server"); host != "" && current.Country != "" { + current.Hostname = host + *out = append(*out, current) + } + for _, child := range value { + collectServers(child, current, out) + } + } +} + +func groupCountries(servers []serverChoice) []countryChoice { + byName := map[string]countryChoice{} + for _, server := range servers { + name := strings.TrimSpace(server.Country) + host := strings.TrimSpace(server.Hostname) + if name == "" || host == "" { + continue + } + choice := byName[name] + if choice.Name == "" { + choice.Name = name + choice.Hostname = host + } + choice.Count++ + byName[name] = choice + } + countries := make([]countryChoice, 0, len(byName)) + for _, choice := range byName { + countries = append(countries, choice) + } + sort.Slice(countries, func(i, j int) bool { + return strings.ToLower(countries[i].Name) < strings.ToLower(countries[j].Name) + }) + return countries +} + +func firstString(values map[string]any, keys ...string) string { + for _, key := range keys { + for actual, value := range values { + if normalizeKey(actual) == normalizeKey(key) { + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + } + } + } + return "" +} + +func hasAnyKey(values map[string]any, keys ...string) bool { + for actual := range values { + for _, key := range keys { + if normalizeKey(actual) == normalizeKey(key) { + return true + } + } + } + return false +} + +func normalizeKey(key string) string { + key = strings.ToLower(key) + key = strings.ReplaceAll(key, "_", "") + key = strings.ReplaceAll(key, "-", "") + return key +} + +func clamp(value, low, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func isTTY() bool { + info, err := os.Stdout.Stat() + return err == nil && (info.Mode()&os.ModeCharDevice) != 0 +} + +func printSummary() error { + s := loadSnapshot() + if s.Err != "" { + return fmt.Errorf("%s", s.Err) + } + fmt.Println(emptyFallback(s.Version, "Mozilla VPN")) + fmt.Println(emptyFallback(s.Status, "No status output.")) + fmt.Printf("mozillavpn.service: %s\n", emptyFallback(s.Service, "unknown")) + return nil +} + +func printCountries() error { + countries := loadCountries() + if len(countries) == 0 { + return fmt.Errorf("no countries available; login and refresh Mozilla VPN server data first") + } + for _, country := range countries { + fmt.Printf("%-28s %3d %s\n", country.Name, country.Count, country.Hostname) + } + return nil +} + +func selectCountryByName(query string) error { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return fmt.Errorf("country name is required") + } + countries := loadCountries() + for _, country := range countries { + if strings.Contains(strings.ToLower(country.Name), query) { + out, err := run(30*time.Second, "mozillavpn", "select", country.Hostname) + if strings.TrimSpace(out) != "" { + fmt.Println(out) + } + return err + } + } + return fmt.Errorf("country not found: %s", query) +} + +func main() { + args := os.Args[1:] + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + switch args[0] { + case "status": + if err := printSummary(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + case "countries": + if err := printCountries(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + case "select", "country", "select-country": + if err := selectCountryByName(strings.Join(args[1:], " ")); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + case "ui", "open-ui": + out, err := openMozillaVPNUI() + if out != "" { + fmt.Println(out) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + case "login": + out, err := startLoginFlow() + if out != "" { + fmt.Println(out) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + case "activate", "deactivate": + out, err := run(30*time.Second, "mozillavpn", args[0]) + if strings.TrimSpace(out) != "" { + fmt.Println(out) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + } + + fs := flag.NewFlagSet("mozilla-vpn-tui", flag.ExitOnError) + once := fs.Bool("once", false, "render once and exit") + interval := fs.Duration("interval", 8*time.Second, "refresh interval") + fs.Parse(args) + + model := model{interval: *interval, loading: true} + if *once || !isTTY() { + model.loading = false + model.data = loadSnapshot() + model.width = 52 + fmt.Println(model.render(48)) + return + } + + program := tea.NewProgram(model) + if _, err := program.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/mozilla_vpn_tui.sh b/scripts/mozilla_vpn_tui.sh new file mode 100755 index 0000000..bf67999 --- /dev/null +++ b/scripts/mozilla_vpn_tui.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +source "$SCRIPT_DIR/lib/common.sh" + +ROOT_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +CACHE_DIR="$ROOT_DIR/workspace/tmp/bin" +BINARY="$CACHE_DIR/cento-mozilla-vpn-tui" +SOURCE_FILE="$ROOT_DIR/scripts/mozilla_vpn_tui.go" +GO_MOD="$ROOT_DIR/go.mod" +GO_SUM="$ROOT_DIR/go.sum" + +cento_require_cmd go +cento_ensure_dir "$CACHE_DIR" +export CENTO_ROOT_DIR="$ROOT_DIR" +unset NO_COLOR +export CLICOLOR=1 +export CLICOLOR_FORCE=1 +export COLORTERM="${COLORTERM:-truecolor}" + +if [[ ! -x "$BINARY" || "$SOURCE_FILE" -nt "$BINARY" || "$GO_MOD" -nt "$BINARY" || ( -f "$GO_SUM" && "$GO_SUM" -nt "$BINARY" ) ]]; then + (cd -- "$ROOT_DIR" && go build -o "$BINARY" ./scripts/mozilla_vpn_tui.go) +fi + +exec "$BINARY" "$@" diff --git a/scripts/object_storage.py b/scripts/object_storage.py new file mode 100644 index 0000000..9e5335e --- /dev/null +++ b/scripts/object_storage.py @@ -0,0 +1,916 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import configparser +import hashlib +import json +import mimetypes +import os +import shlex +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_RUN_ROOT = ROOT / "workspace" / "runs" / "object-storage" +SCHEMA_VERSION = "cento.object_storage_mvp.v1" +E2E_SCHEMA_VERSION = "cento.object_storage_mvp_e2e.v1" +IMAGE_MIGRATION_SCHEMA_VERSION = "cento.object_storage_image_migration.v1" +IMAGE_UPLOAD_SCHEMA_VERSION = "cento.object_storage_image_upload.v1" +IMAGE_VERIFY_SCHEMA_VERSION = "cento.object_storage_image_verify.v1" +DEFAULT_IMAGE_BUCKET = "cento-images-standard" +DEFAULT_IMAGE_ROOT = ROOT / "workspace" / "runs" +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".xwd"} +SKIPPED_DIRS = {".git", ".venv", "__pycache__", "node_modules", "venv"} +SENSITIVE_NAME_FRAGMENTS = {"token", "secret"} +STANDARD_OBJECT_STORAGE_RATE_PER_GIB_MONTH = 0.0255 + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def run_id(prefix: str = "object-storage") -> str: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return f"{prefix}-{stamp}" + + +def repo_path(value: str | Path) -> Path: + path = Path(value).expanduser() + return path if path.is_absolute() else ROOT / path + + +def rel(path: Path) -> str: + try: + return str(path.resolve().relative_to(ROOT)) + except ValueError: + return str(path) + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def env_first(*names: str) -> str: + for name in names: + value = os.environ.get(name, "").strip() + if value: + return value + return "" + + +def configured_bucket(value: str = "") -> str: + return value.strip() or env_first("CENTO_OBJECT_STORAGE_BUCKET", "CENTO_OCI_BUCKET", "OCI_OBJECT_STORAGE_BUCKET") + + +def configured_namespace(value: str = "") -> str: + return value.strip() or env_first("CENTO_OBJECT_STORAGE_NAMESPACE", "CENTO_OCI_NAMESPACE", "OCI_OBJECT_STORAGE_NAMESPACE") + + +def configured_compartment(value: str = "") -> str: + return value.strip() or env_first("CENTO_OBJECT_STORAGE_COMPARTMENT_ID", "CENTO_OCI_COMPARTMENT_ID", "OCI_COMPARTMENT_ID") + + +def configured_region(value: str = "") -> str: + return value.strip() or env_first("CENTO_OBJECT_STORAGE_REGION", "CENTO_OCI_REGION", "OCI_CLI_REGION", "OCI_REGION") + + +def configured_prefix(value: str = "") -> str: + return (value.strip() or env_first("CENTO_OBJECT_STORAGE_PREFIX") or "cento/dummy").strip("/") + + +def safe_uploaded_path(root: Path, object_name: str) -> Path: + parts = [part for part in PurePosixPath(object_name).parts if part not in {"", ".", ".."}] + if not parts: + parts = ["dummy.txt"] + return root.joinpath(*parts) + + +def configured_image_bucket(value: str = "") -> str: + return configured_bucket(value) or DEFAULT_IMAGE_BUCKET + + +def tenancy_from_config(config_file: str = "", profile: str = "") -> str: + config_path = Path(config_file).expanduser() if config_file else Path.home() / ".oci" / "config" + if not config_path.exists(): + return "" + parser = configparser.RawConfigParser() + parser.read(config_path) + section = profile or "DEFAULT" + if parser.has_section(section): + return parser.get(section, "tenancy", fallback="").strip() + return parser.defaults().get("tenancy", "").strip() + + +def image_content_type(path: Path) -> str: + if path.suffix.lower() == ".xwd": + return "application/octet-stream" + return mimetypes.guess_type(path.name)[0] or "application/octet-stream" + + +def is_sensitive_image_path(path: Path) -> bool: + parts = [part.lower() for part in path.parts] + name = path.name.lower() + if name == "key4.db" or name.endswith(".pem") or name.startswith(".env"): + return True + if any(part.startswith(".env") for part in parts): + return True + return any(fragment in part for fragment in SENSITIVE_NAME_FRAGMENTS for part in parts) + + +def image_artifact_class(path: Path) -> str: + suffix = path.suffix.lower() + lower_parts = [part.lower() for part in path.parts] + if suffix == ".xwd": + return "screenshot_raw" + if suffix in IMAGE_SUFFIXES and any("screenshot" in part for part in lower_parts): + return "screenshot_normalized" + return "image" + + +def image_sensitivity(path: Path, blocked: bool) -> str: + if blocked: + return "secret_risk" + return "internal" + + +def iter_image_files(root: Path) -> list[Path]: + if not root.exists(): + raise SystemExit(f"Image root does not exist: {root}") + files: list[Path] = [] + for current, dirs, names in os.walk(root): + dirs[:] = [item for item in dirs if item not in SKIPPED_DIRS] + current_path = Path(current) + for name in names: + path = current_path / name + if path.is_symlink() or not path.is_file(): + continue + if path.suffix.lower() in IMAGE_SUFFIXES: + files.append(path) + return sorted(files) + + +def object_name_for_image(path: Path, sha256: str) -> str: + filename = PurePosixPath(path.name).name or "image" + return f"cento/images/v1/objects/sha256/{sha256[:2]}/{sha256}/{filename}" + + +def object_uri(namespace: str, bucket: str, object_name: str) -> str: + return f"oci://{namespace or ''}/{bucket or ''}/{object_name}" + + +def parse_json_stdout(result: dict[str, Any]) -> dict[str, Any]: + try: + payload = json.loads(result.get("stdout") or "{}") + except json.JSONDecodeError as exc: + raise SystemExit(f"Expected JSON output from `{result.get('command')}`: {exc}") from exc + if not isinstance(payload, dict): + raise SystemExit(f"Expected JSON object from `{result.get('command')}`") + return payload + + +def oci_global_args(args: argparse.Namespace) -> list[str]: + global_args: list[str] = [] + if getattr(args, "region", ""): + global_args.extend(["--region", str(args.region)]) + if getattr(args, "config_file", ""): + global_args.extend(["--config-file", str(args.config_file)]) + if getattr(args, "profile", ""): + global_args.extend(["--profile", str(args.profile)]) + return global_args + + +def run_command(command: list[str], cwd: Path = ROOT) -> dict[str, Any]: + result = subprocess.run(command, cwd=cwd, text=True, capture_output=True) + return { + "command": shlex.join(command), + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + +def render_summary(path: Path, receipt: dict[str, Any]) -> None: + lines = [ + "# Cento Object Storage MVP", + "", + f"- status: `{receipt.get('status', '')}`", + f"- mode: `{receipt.get('mode', '')}`", + f"- bucket: `{receipt.get('bucket') or 'not configured'}`", + f"- namespace: `{receipt.get('namespace') or 'auto'}`", + f"- object: `{receipt.get('object_name', '')}`", + f"- dummy file: `{receipt.get('dummy_file', '')}`", + f"- sha256: `{receipt.get('sha256', '')}`", + f"- receipt: `{receipt.get('receipt', '')}`", + ] + if receipt.get("error"): + lines.extend(["", "## Error", "", str(receipt["error"])]) + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + + +def base_receipt(args: argparse.Namespace, out: Path, dummy_file: Path, object_name: str, mode: str) -> dict[str, Any]: + bucket = configured_bucket(getattr(args, "bucket", "")) + namespace = configured_namespace(getattr(args, "namespace", "")) + region = configured_region(getattr(args, "region", "")) + receipt_path = out / "receipt.json" + return { + "schema_version": SCHEMA_VERSION, + "run_id": out.name, + "status": "initialized", + "mode": mode, + "written_at": now_iso(), + "bucket": bucket, + "namespace": namespace, + "region": region, + "object_name": object_name, + "object_uri": f"oci://{namespace or ''}/{bucket or ''}/{object_name}", + "dummy_file": rel(dummy_file), + "receipt": rel(receipt_path), + "summary": rel(out / "summary.md"), + "sha256": sha256_file(dummy_file), + "size_bytes": dummy_file.stat().st_size, + "oci_cli": shutil.which(getattr(args, "oci_bin", "oci")) or getattr(args, "oci_bin", "oci"), + } + + +def write_dummy_file(out: Path, content: str) -> Path: + dummy_file = out / "dummy.txt" + dummy_file.parent.mkdir(parents=True, exist_ok=True) + dummy_file.write_text(content, encoding="utf-8") + return dummy_file + + +def object_name_for(args: argparse.Namespace, current_run_id: str) -> str: + if getattr(args, "object_name", ""): + return str(args.object_name).strip().lstrip("/") + prefix = configured_prefix(getattr(args, "prefix", "")) + return f"{prefix}/{current_run_id}/dummy.txt" if prefix else f"{current_run_id}/dummy.txt" + + +def put_dummy(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + current_run_id = str(getattr(args, "run_id", "") or run_id()).strip() + out = repo_path(getattr(args, "out", "") or DEFAULT_RUN_ROOT / current_run_id) + out.mkdir(parents=True, exist_ok=True) + object_name = object_name_for(args, current_run_id) + dummy_file = write_dummy_file(out, getattr(args, "content", "") or "cento object storage dummy\n") + mode = "dry-run" if getattr(args, "dry_run", False) else "live" + receipt = base_receipt(args, out, dummy_file, object_name, mode) + receipt_path = out / "receipt.json" + + if mode == "dry-run": + uploaded = safe_uploaded_path(out / "uploaded", object_name) + uploaded.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(dummy_file, uploaded) + receipt.update( + { + "status": "uploaded-dry-run", + "uploaded_copy": rel(uploaded), + "verified": sha256_file(uploaded) == receipt["sha256"], + "note": "Dry-run copied the dummy file into the run-scoped uploaded/ directory instead of calling OCI.", + } + ) + write_json(receipt_path, receipt) + render_summary(out / "summary.md", receipt) + return 0, receipt + + if not receipt["bucket"]: + receipt.update( + { + "status": "blocked", + "error": "Missing bucket. Pass --bucket or set CENTO_OBJECT_STORAGE_BUCKET.", + } + ) + write_json(receipt_path, receipt) + render_summary(out / "summary.md", receipt) + return 2, receipt + + command = [ + getattr(args, "oci_bin", "oci"), + "os", + "object", + "put", + "--bucket-name", + receipt["bucket"], + "--file", + str(dummy_file), + "--name", + object_name, + "--force", + "--no-multipart", + "--content-type", + "text/plain", + *oci_global_args(args), + ] + if receipt["namespace"]: + command.extend(["--namespace-name", receipt["namespace"]]) + + command_result = run_command(command) + (out / "oci.stdout.log").write_text(command_result["stdout"], encoding="utf-8") + (out / "oci.stderr.log").write_text(command_result["stderr"], encoding="utf-8") + receipt["oci"] = { + "command": command_result["command"], + "returncode": command_result["returncode"], + "stdout_log": rel(out / "oci.stdout.log"), + "stderr_log": rel(out / "oci.stderr.log"), + } + if command_result["returncode"] == 0: + receipt["status"] = "uploaded" + receipt["verified"] = True + else: + receipt["status"] = "failed" + receipt["verified"] = False + receipt["error"] = command_result["stderr"].strip() or "OCI object put failed" + + write_json(receipt_path, receipt) + render_summary(out / "summary.md", receipt) + return (0 if receipt["status"] == "uploaded" else 1), receipt + + +def status(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + oci_bin = getattr(args, "oci_bin", "oci") + oci_path = shutil.which(oci_bin) or "" + config_file = Path(getattr(args, "config_file", "") or Path.home() / ".oci" / "config") + payload: dict[str, Any] = { + "schema_version": "cento.object_storage_status.v1", + "ok": bool(oci_path and config_file.exists()), + "oci_cli": oci_path, + "config_file": str(config_file), + "config_exists": config_file.exists(), + "bucket_configured": bool(configured_bucket(getattr(args, "bucket", ""))), + "namespace_configured": bool(configured_namespace(getattr(args, "namespace", ""))), + "compartment_configured": bool(configured_compartment(getattr(args, "compartment_id", ""))), + "region": configured_region(getattr(args, "region", "")), + } + if getattr(args, "probe", False) and oci_path: + command = [oci_bin, "os", "ns", "get", *oci_global_args(args)] + probe = run_command(command) + payload["probe"] = { + "command": probe["command"], + "returncode": probe["returncode"], + "stdout": probe["stdout"], + "stderr": probe["stderr"], + } + payload["ok"] = payload["ok"] and probe["returncode"] == 0 + return (0 if payload["ok"] else 1), payload + + +def run_e2e(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + current_run_id = str(getattr(args, "run_id", "") or run_id("object-storage-e2e")).strip() + out = repo_path(getattr(args, "out", "") or DEFAULT_RUN_ROOT / current_run_id) + put_args = argparse.Namespace(**vars(args)) + put_args.run_id = current_run_id + put_args.out = str(out) + put_args.content = getattr(args, "content", "") or "cento object storage e2e dummy\n" + put_args.dry_run = not bool(getattr(args, "live", False)) + code, receipt = put_dummy(put_args) + dummy_file = repo_path(receipt["dummy_file"]) + checks = [ + {"name": "dummy file exists", "passed": dummy_file.exists(), "evidence": receipt["dummy_file"]}, + {"name": "receipt exists", "passed": repo_path(receipt["receipt"]).exists(), "evidence": receipt["receipt"]}, + {"name": "sha256 recorded", "passed": bool(receipt.get("sha256")), "evidence": receipt["receipt"]}, + ] + if receipt["mode"] == "dry-run": + uploaded = repo_path(str(receipt.get("uploaded_copy") or "")) + checks.append({"name": "dry-run uploaded copy exists", "passed": uploaded.exists(), "evidence": rel(uploaded)}) + checks.append({"name": "dry-run uploaded copy matches", "passed": uploaded.exists() and sha256_file(uploaded) == receipt.get("sha256"), "evidence": rel(uploaded)}) + else: + checks.append({"name": "live OCI upload completed", "passed": receipt.get("status") == "uploaded", "evidence": receipt["receipt"]}) + + passed = all(item["passed"] for item in checks) + summary = { + "schema_version": E2E_SCHEMA_VERSION, + "run_id": current_run_id, + "status": "passed" if passed else "failed", + "mode": receipt["mode"], + "out": rel(out), + "receipt": receipt, + "checks": checks, + "ai_calls_used": 0, + "estimated_ai_cost_usd": 0, + "written_at": now_iso(), + } + write_json(out / "e2e-summary.json", summary) + lines = [ + "# Cento Object Storage MVP E2E", + "", + f"- status: `{summary['status']}`", + f"- mode: `{summary['mode']}`", + f"- receipt: `{receipt['receipt']}`", + "", + "## Checks", + "", + *[f"- {'PASS' if item['passed'] else 'FAIL'} `{item['name']}`: `{item['evidence']}`" for item in checks], + ] + (out / "e2e-summary.md").write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + return (0 if passed and code == 0 else 1), summary + + +def bucket_get(args: argparse.Namespace, bucket: str, namespace: str) -> dict[str, Any]: + command = [ + getattr(args, "oci_bin", "oci"), + "os", + "bucket", + "get", + "--bucket-name", + bucket, + *oci_global_args(args), + ] + if namespace: + command.extend(["--namespace-name", namespace]) + return run_command(command) + + +def bucket_is_private_standard(bucket_data: dict[str, Any]) -> bool: + return bucket_data.get("storage-tier") == "Standard" and bucket_data.get("public-access-type") == "NoPublicAccess" + + +def ensure_bucket(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + bucket = str(getattr(args, "name", "") or configured_image_bucket(getattr(args, "bucket", ""))).strip() + namespace = configured_namespace(getattr(args, "namespace", "")) + compartment_id = configured_compartment(getattr(args, "compartment_id", "")) or tenancy_from_config( + getattr(args, "config_file", ""), + getattr(args, "profile", ""), + ) + payload: dict[str, Any] = { + "schema_version": "cento.object_storage_bucket.v1", + "bucket": bucket, + "namespace": namespace, + "region": configured_region(getattr(args, "region", "")), + "storage_tier": "Standard", + "public_access_type": "NoPublicAccess", + "written_at": now_iso(), + } + if not bucket: + payload.update({"status": "blocked", "error": "Missing bucket name."}) + return 2, payload + + get_result = bucket_get(args, bucket, namespace) + if get_result["returncode"] == 0: + data = parse_json_stdout(get_result).get("data", {}) + payload["bucket_data"] = data + payload["status"] = "exists" if bucket_is_private_standard(data) else "blocked" + if payload["status"] == "blocked": + payload["error"] = "Bucket exists but is not Standard tier with NoPublicAccess." + return (0 if payload["status"] == "exists" else 1), payload + + if not compartment_id: + payload.update( + { + "status": "blocked", + "error": "Missing compartment. Pass --compartment-id or configure tenancy in ~/.oci/config.", + "bucket_get_stderr": get_result["stderr"], + } + ) + return 2, payload + + command = [ + getattr(args, "oci_bin", "oci"), + "os", + "bucket", + "create", + "--compartment-id", + compartment_id, + "--name", + bucket, + "--public-access-type", + "NoPublicAccess", + "--storage-tier", + "Standard", + *oci_global_args(args), + ] + if namespace: + command.extend(["--namespace-name", namespace]) + create_result = run_command(command) + payload["oci"] = { + "command": create_result["command"], + "returncode": create_result["returncode"], + } + if create_result["returncode"] != 0: + payload.update({"status": "failed", "error": create_result["stderr"].strip() or "OCI bucket create failed"}) + return 1, payload + + data = parse_json_stdout(create_result).get("data", {}) + payload["bucket_data"] = data + payload["status"] = "created" if bucket_is_private_standard(data) else "blocked" + if payload["status"] == "blocked": + payload["error"] = "Created bucket did not report Standard tier with NoPublicAccess." + return (0 if payload["status"] == "created" else 1), payload + + +def image_migration_run_dir(args: argparse.Namespace, prefix: str = "image-migration") -> Path: + current_run_id = str(getattr(args, "run_id", "") or run_id(prefix)).strip() + return repo_path(getattr(args, "out", "") or DEFAULT_RUN_ROOT / current_run_id) + + +def render_image_summary(path: Path, payload: dict[str, Any]) -> None: + totals = payload.get("totals", {}) + lines = [ + "# Cento OCI Image Migration", + "", + f"- status: `{payload.get('status', '')}`", + f"- mode: `{payload.get('mode', '')}`", + f"- bucket: `{payload.get('bucket', '')}`", + f"- namespace: `{payload.get('namespace', '')}`", + f"- region: `{payload.get('region', '')}`", + f"- files: `{totals.get('files', 0)}`", + f"- unique upload objects: `{totals.get('unique_upload_objects', 0)}`", + f"- blocked: `{totals.get('blocked_files', 0)}`", + f"- duplicate rows: `{totals.get('duplicate_rows', 0)}`", + f"- bytes: `{totals.get('bytes', 0)}`", + ] + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + + +def plan_images(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + out = image_migration_run_dir(args) + root = repo_path(getattr(args, "root", "") or DEFAULT_IMAGE_ROOT) + bucket = configured_image_bucket(getattr(args, "bucket", "")) + namespace = configured_namespace(getattr(args, "namespace", "")) + region = configured_region(getattr(args, "region", "")) + rows: list[dict[str, Any]] = [] + seen_hashes: set[str] = set() + + for path in iter_image_files(root): + sha256 = sha256_file(path) + blocked = is_sensitive_image_path(path) + duplicate = sha256 in seen_hashes + if not blocked: + seen_hashes.add(sha256) + object_name = "" if blocked else object_name_for_image(path, sha256) + rows.append( + { + "artifact_id": "image-" + hashlib.sha256(rel(path).encode("utf-8")).hexdigest()[:20], + "source_path": rel(path), + "size_bytes": path.stat().st_size, + "sha256": sha256, + "extension": path.suffix.lower(), + "content_type": image_content_type(path), + "artifact_class": image_artifact_class(path), + "sensitivity": image_sensitivity(path, blocked), + "blocked": blocked, + "block_reason": "sensitive_path" if blocked else "", + "upload_role": "blocked" if blocked else ("duplicate" if duplicate else "primary"), + "upload_status": "blocked_sensitive_path" if blocked else ("dedupe_reference" if duplicate else "planned"), + "object_name": object_name, + "object_uri": "" if blocked else object_uri(namespace, bucket, object_name), + } + ) + + unique_upload_bytes = sum(row["size_bytes"] for row in rows if row["upload_role"] == "primary") + payload = { + "schema_version": IMAGE_MIGRATION_SCHEMA_VERSION, + "run_id": out.name, + "status": "planned", + "mode": "mirror-only", + "root": rel(root), + "out": rel(out), + "bucket": bucket, + "namespace": namespace, + "region": region, + "manifest": rel(out / "manifest.json"), + "summary": rel(out / "summary.md"), + "written_at": now_iso(), + "totals": { + "files": len(rows), + "bytes": sum(row["size_bytes"] for row in rows), + "unique_upload_objects": sum(1 for row in rows if row["upload_role"] == "primary"), + "unique_upload_bytes": unique_upload_bytes, + "blocked_files": sum(1 for row in rows if row["blocked"]), + "duplicate_rows": sum(1 for row in rows if row["upload_role"] == "duplicate"), + "estimated_standard_storage_gib": round(unique_upload_bytes / (1024**3), 6), + "estimated_standard_storage_usd_month": round( + unique_upload_bytes / (1024**3) * STANDARD_OBJECT_STORAGE_RATE_PER_GIB_MONTH, + 6, + ), + }, + "rows": rows, + } + write_json(out / "manifest.json", payload) + render_image_summary(out / "summary.md", payload) + return 0, payload + + +def validate_upload_bucket(args: argparse.Namespace, bucket: str, namespace: str) -> tuple[bool, str]: + result = bucket_get(args, bucket, namespace) + if result["returncode"] != 0: + return False, result["stderr"].strip() or "Unable to inspect OCI bucket." + data = parse_json_stdout(result).get("data", {}) + if not bucket_is_private_standard(data): + return False, "Bucket must be Standard tier with NoPublicAccess." + return True, "" + + +def load_manifest(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or not isinstance(payload.get("rows"), list): + raise SystemExit(f"Expected image migration manifest with rows: {path}") + return payload + + +def upload_images(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + manifest_path = repo_path(getattr(args, "manifest", "")) + manifest = load_manifest(manifest_path) + out = repo_path(getattr(args, "out", "") or manifest_path.parent) + bucket = configured_image_bucket(getattr(args, "bucket", "") or str(manifest.get("bucket", ""))) + namespace = configured_namespace(getattr(args, "namespace", "") or str(manifest.get("namespace", ""))) + region = configured_region(getattr(args, "region", "") or str(manifest.get("region", ""))) + live = bool(getattr(args, "live", False)) + rows = [dict(row) for row in manifest["rows"]] + status_by_sha: dict[str, dict[str, Any]] = {} + + receipt = { + **{key: value for key, value in manifest.items() if key != "rows"}, + "schema_version": IMAGE_UPLOAD_SCHEMA_VERSION, + "status": "initialized", + "mode": "live" if live else "dry-run", + "bucket": bucket, + "namespace": namespace, + "region": region, + "source_manifest": rel(manifest_path), + "out": rel(out), + "upload_receipt": rel(out / "upload-receipt.json"), + "written_at": now_iso(), + "rows": rows, + } + + if live: + ok, error = validate_upload_bucket(args, bucket, namespace) + if not ok: + receipt.update({"status": "blocked", "error": error}) + write_json(out / "upload-receipt.json", receipt) + render_image_summary(out / "upload-summary.md", receipt) + return 2, receipt + + for row in rows: + if row.get("blocked"): + continue + sha256 = str(row["sha256"]) + if sha256 in status_by_sha: + previous = status_by_sha[sha256] + row["upload_status"] = "dedupe_reference" + row["upload_verified"] = bool(previous.get("upload_verified")) + row["uploaded_copy"] = previous.get("uploaded_copy", "") + continue + + source = repo_path(row["source_path"]) + if live: + command = [ + getattr(args, "oci_bin", "oci"), + "os", + "object", + "put", + "--bucket-name", + bucket, + "--file", + str(source), + "--name", + row["object_name"], + "--force", + "--content-type", + row.get("content_type") or image_content_type(source), + *oci_global_args(argparse.Namespace(**{**vars(args), "region": region})), + ] + if namespace: + command.extend(["--namespace-name", namespace]) + result = run_command(command) + row["oci_command"] = result["command"] + row["oci_returncode"] = result["returncode"] + if result["returncode"] == 0: + row["upload_status"] = "uploaded" + row["upload_verified"] = True + else: + row["upload_status"] = "failed" + row["upload_verified"] = False + row["error"] = result["stderr"].strip() or "OCI object put failed" + else: + uploaded = safe_uploaded_path(out / "uploaded", row["object_name"]) + uploaded.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, uploaded) + row["upload_status"] = "uploaded-dry-run" + row["uploaded_copy"] = rel(uploaded) + row["upload_verified"] = sha256_file(uploaded) == sha256 + + status_by_sha[sha256] = row + + failures = [row for row in rows if row.get("upload_status") == "failed"] + uploaded = [row for row in rows if row.get("upload_status") in {"uploaded", "uploaded-dry-run"}] + receipt["totals"] = { + **dict(receipt.get("totals", {})), + "uploaded_objects": len(uploaded), + "failed_objects": len(failures), + } + receipt["status"] = "failed" if failures else ("uploaded" if live else "uploaded-dry-run") + write_json(out / "upload-receipt.json", receipt) + render_image_summary(out / "upload-summary.md", receipt) + return (1 if failures else 0), receipt + + +def verify_images(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + manifest_path = repo_path(getattr(args, "manifest", "")) + manifest = load_manifest(manifest_path) + out = repo_path(getattr(args, "out", "") or manifest_path.parent) + sample = int(getattr(args, "sample", 10)) + bucket = configured_image_bucket(getattr(args, "bucket", "") or str(manifest.get("bucket", ""))) + namespace = configured_namespace(getattr(args, "namespace", "") or str(manifest.get("namespace", ""))) + region = configured_region(getattr(args, "region", "") or str(manifest.get("region", ""))) + rows: list[dict[str, Any]] = [] + seen_hashes: set[str] = set() + for row in manifest["rows"]: + status = row.get("upload_status") + if row.get("blocked") or status not in {"uploaded", "uploaded-dry-run", "dedupe_reference"}: + continue + sha256 = str(row["sha256"]) + if sha256 in seen_hashes: + continue + seen_hashes.add(sha256) + rows.append(dict(row)) + if sample > 0: + rows = rows[:sample] + + checks: list[dict[str, Any]] = [] + for row in rows: + expected_sha = str(row["sha256"]) + if row.get("upload_status") == "uploaded-dry-run" and row.get("uploaded_copy"): + candidate = repo_path(str(row["uploaded_copy"])) + actual_sha = sha256_file(candidate) if candidate.exists() else "" + checks.append( + { + "source_path": row["source_path"], + "object_name": row["object_name"], + "verified": actual_sha == expected_sha, + "mode": "dry-run-copy", + "sha256": expected_sha, + } + ) + continue + + download = out / "verify-downloads" / expected_sha[:2] / f"{expected_sha}-{Path(row['source_path']).name}" + download.parent.mkdir(parents=True, exist_ok=True) + command = [ + getattr(args, "oci_bin", "oci"), + "os", + "object", + "get", + "--bucket-name", + bucket, + "--name", + row["object_name"], + "--file", + str(download), + *oci_global_args(argparse.Namespace(**{**vars(args), "region": region})), + ] + if namespace: + command.extend(["--namespace-name", namespace]) + result = run_command(command) + actual_sha = sha256_file(download) if result["returncode"] == 0 and download.exists() else "" + checks.append( + { + "source_path": row["source_path"], + "object_name": row["object_name"], + "downloaded_file": rel(download), + "verified": actual_sha == expected_sha, + "mode": "oci-get", + "sha256": expected_sha, + "oci_returncode": result["returncode"], + "error": "" if result["returncode"] == 0 else result["stderr"].strip(), + } + ) + + passed = all(check["verified"] for check in checks) + receipt = { + "schema_version": IMAGE_VERIFY_SCHEMA_VERSION, + "status": "passed" if passed else "failed", + "source_manifest": rel(manifest_path), + "out": rel(out), + "bucket": bucket, + "namespace": namespace, + "region": region, + "sample": sample, + "checks": checks, + "verify_receipt": rel(out / "verify-receipt.json"), + "written_at": now_iso(), + } + write_json(out / "verify-receipt.json", receipt) + return (0 if passed else 1), receipt + + +def add_common_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--bucket", default="", help="OCI Object Storage bucket name. Defaults to CENTO_OBJECT_STORAGE_BUCKET.") + parser.add_argument("--namespace", default="", help="OCI Object Storage namespace. Defaults to CENTO_OBJECT_STORAGE_NAMESPACE or OCI auto-discovery.") + parser.add_argument("--compartment-id", default="", help="Compartment OCID for status/list-oriented commands.") + parser.add_argument("--region", default="", help="OCI region for Object Storage calls, for example us-ashburn-1.") + parser.add_argument("--profile", default="", help="OCI CLI profile.") + parser.add_argument("--config-file", default="", help="OCI CLI config path.") + parser.add_argument("--oci-bin", default="oci", help="OCI CLI executable.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Cento Oracle Object Storage MVP.") + sub = parser.add_subparsers(dest="command", required=True) + + status_parser = sub.add_parser("status", help="Check local OCI Object Storage configuration.") + add_common_options(status_parser) + status_parser.add_argument("--probe", action="store_true", help="Call `oci os ns get` to verify authentication.") + status_parser.add_argument("--json", action="store_true") + + bucket_parser = sub.add_parser("ensure-bucket", help="Create or verify a private Standard OCI image bucket.") + add_common_options(bucket_parser) + bucket_parser.add_argument("--name", default=DEFAULT_IMAGE_BUCKET, help="Bucket name to create or verify.") + bucket_parser.add_argument("--json", action="store_true") + + put_parser = sub.add_parser("put-dummy", help="Write a dummy file and upload it to OCI Object Storage.") + add_common_options(put_parser) + put_parser.add_argument("--run-id", default="") + put_parser.add_argument("--out", default="") + put_parser.add_argument("--prefix", default="") + put_parser.add_argument("--object-name", default="") + put_parser.add_argument("--content", default="") + put_parser.add_argument("--dry-run", action="store_true", help="Do not call OCI; copy the dummy file into uploaded/.") + put_parser.add_argument("--json", action="store_true") + + e2e_parser = sub.add_parser("e2e", help="Run deterministic Object Storage MVP end-to-end validation.") + add_common_options(e2e_parser) + e2e_parser.add_argument("--run-id", default="") + e2e_parser.add_argument("--out", default="") + e2e_parser.add_argument("--prefix", default="") + e2e_parser.add_argument("--object-name", default="") + e2e_parser.add_argument("--content", default="") + e2e_parser.add_argument("--live", action="store_true", help="Use live OCI upload instead of dry-run fixture mode.") + e2e_parser.add_argument("--json", action="store_true") + + plan_images_parser = sub.add_parser("plan-images", help="Write a mirror-only OCI image migration manifest.") + add_common_options(plan_images_parser) + plan_images_parser.add_argument("--run-id", default="") + plan_images_parser.add_argument("--root", default=str(DEFAULT_IMAGE_ROOT)) + plan_images_parser.add_argument("--out", default="") + plan_images_parser.add_argument("--json", action="store_true") + + upload_images_parser = sub.add_parser("upload-images", help="Upload image manifest objects to OCI or a dry-run copy.") + add_common_options(upload_images_parser) + upload_images_parser.add_argument("--manifest", required=True) + upload_images_parser.add_argument("--out", default="") + upload_images_parser.add_argument("--live", action="store_true") + upload_images_parser.add_argument("--json", action="store_true") + + verify_images_parser = sub.add_parser("verify-images", help="Download and verify uploaded image objects.") + add_common_options(verify_images_parser) + verify_images_parser.add_argument("--manifest", required=True) + verify_images_parser.add_argument("--out", default="") + verify_images_parser.add_argument("--sample", type=int, default=10) + verify_images_parser.add_argument("--json", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.command == "status": + code, payload = status(args) + elif args.command == "ensure-bucket": + code, payload = ensure_bucket(args) + elif args.command == "put-dummy": + code, payload = put_dummy(args) + elif args.command == "e2e": + code, payload = run_e2e(args) + elif args.command == "plan-images": + code, payload = plan_images(args) + elif args.command == "upload-images": + code, payload = upload_images(args) + elif args.command == "verify-images": + code, payload = verify_images(args) + else: + raise SystemExit(f"unknown command: {args.command}") + + if getattr(args, "json", False): + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + if args.command == "status": + print("ok" if payload.get("ok") else "not ready") + elif args.command == "ensure-bucket": + print(payload.get("status", "")) + elif args.command in {"e2e", "plan-images"}: + print(payload.get("out", "")) + elif args.command == "upload-images": + print(payload.get("upload_receipt", "")) + elif args.command == "verify-images": + print(payload.get("verify_receipt", "")) + else: + print(payload.get("receipt", "")) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery.py b/scripts/parallel_delivery.py new file mode 100644 index 0000000..42e544e --- /dev/null +++ b/scripts/parallel_delivery.py @@ -0,0 +1,5296 @@ +#!/usr/bin/env python3 +"""Parallel AI Delivery coordinator. + +This is the durable implementation surface for the roadmap in +docs/parallel-ai-delivery-roadmap.md. It deliberately routes through existing +Cento pipelines: Hard ProReq for requirement/manifests/image prompts, Workset +for parallel worker shape, and local receipts for integration/demo evidence. +""" + +from __future__ import annotations + +import argparse +from collections import Counter +import difflib +import hashlib +import json +import os +import shutil +import shlex +import subprocess +import sys +import time +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator + + +ROOT = Path(__file__).resolve().parents[1] +RUNS_ROOT = ROOT / "workspace" / "runs" / "parallel-delivery" +TRAIN_RUNS_ROOT = RUNS_ROOT / "train" +PATCH_SWARM_RUNS_ROOT = RUNS_ROOT / "patch-swarm" +FACTORY_RUNS_ROOT = ROOT / "workspace" / "runs" / "factory" +SELF_IMPROVE_RUNS_ROOT = ROOT / "workspace" / "runs" / "ai-self-improvement-nightly" +SELF_IMPROVE_E2E_RUNS_ROOT = ROOT / "workspace" / "runs" / "ai-self-improvement-e2e" +CONTINUOUS_PROREQ_ROOT = ROOT / "workspace" / "runs" / "ai-cento-native-continuous-proreq" +PIPELINE_ROOT = ROOT / "workspace" / "runs" / "dev-pipeline-studio" / "docs-pages" / "latest" +SCHEMA_PLAN = "cento.parallel_delivery.plan.v1" +SCHEMA_RECEIPT = "cento.parallel_delivery.receipt.v1" +SCHEMA_VALIDATION = "cento.parallel_delivery.validation.v1" +SCHEMA_TRAIN = "cento.parallel_integration_train.v1" +SCHEMA_TRAIN_QUEUE = "cento.parallel_integration_train.queue.v1" +SCHEMA_TRAIN_RECEIPT = "cento.parallel_integration_train.receipt.v1" +SCHEMA_PATCH_SWARM = "cento.patch_swarm.manifest.v1" +SCHEMA_PATCH_SWARM_PROREQ = "cento.patch_swarm.proreq_execution_manifest.v1" +SCHEMA_PATCH_SWARM_CANDIDATE = "cento.patch_swarm.candidate_patch.v1" +SCHEMA_PATCH_SWARM_RECEIPT = "cento.patch_swarm.receipt.v1" +SCHEMA_PATCH_SWARM_INTEGRATION = "cento.patch_swarm.integration_execution.v1" +SCHEMA_PATCH_SWARM_VALIDATION = "cento.patch_swarm.validation.v1" +SCHEMA_SELF_MANIFEST = "cento.ai_self_improvement_nightly.manifest.v1" +SCHEMA_SELF_PASS = "cento.ai_self_improvement_nightly.pass_summary.v1" +SCHEMA_SELF_GATES = "cento.ai_self_improvement_nightly.validation_gates.v1" +SCHEMA_SELF_METRICS = "cento.ai_self_improvement_nightly.loop_metrics.v1" +SCHEMA_SELF_PROMOTION = "cento.ai_self_improvement_nightly.promotion_recommendation.v1" +SCHEMA_SELF_HANDOFF = "cento.ai_self_improvement_nightly.evidence_handoff.v1" +SCHEMA_SELF_NEXT = "cento.ai_self_improvement_nightly.next_cycle_request.v1" +SCHEMA_SELF_E2E = "cento.ai_self_improvement_e2e.manifest.v1" +SCHEMA_SELF_E2E_VALIDATION = "cento.ai_self_improvement_e2e.validation.v1" +SELF_CRON_BEGIN = "# BEGIN CENTO AI SELF-IMPROVEMENT NIGHTLY" +SELF_CRON_END = "# END CENTO AI SELF-IMPROVEMENT NIGHTLY" + +sys.path.insert(0, str(ROOT / "scripts")) +import agent_work_app as app # noqa: E402 +import factory as factory_tool # noqa: E402 +import factory_dispatch_core as factory_dispatch # noqa: E402 +import factory_integrator_core as factory_integrator # noqa: E402 +import parallel_delivery_codex_packets as codex_packets_tool # noqa: E402 +import parallel_delivery_leases as lease_tool # noqa: E402 +import parallel_delivery_patch_bundles as patch_bundles_tool # noqa: E402 +import parallel_delivery_patch_swarm_console as patch_swarm_console_tool # noqa: E402 +import parallel_delivery_planner as planner_tool # noqa: E402 +import parallel_delivery_prompts as prompts_tool # noqa: E402 +import parallel_delivery_release_candidate as release_candidate_tool # noqa: E402 +import parallel_delivery_taskstream as taskstream_tool # noqa: E402 +import parallel_delivery_validation_e2e as validation_e2e_tool # noqa: E402 +import parallel_delivery_worker_status as worker_status_tool # noqa: E402 + + +BASE_VISION = ( + "Build the next big Cento delivery system: parse requirements once into exclusive " + "parallel workstreams, run 10 AI workers to produce structured patch/artifact outputs, " + "then converge through 2-3 integrator/validator lanes where integration and validation " + "are deterministic first and AI is called only when deterministic gates cannot classify " + "a conflict, missing evidence, or ambiguity. The target is 2-3 minutes instead of 10 " + "minutes, with only $3-5 marginal AI cost." +) + + +WORKSTREAMS: list[dict[str, str]] = [ + { + "id": "requirements-decomposer", + "title": "Requirements Decomposer", + "focus": "parallel delivery plan schema, split policy, exclusive write path detection, and serialized shared-file task generation", + "image": "requirements intake splitting into 10 owned workstreams and one serialized shared-file lane", + }, + { + "id": "workset-10-worker-executor", + "title": "10 Worker Workset Executor", + "focus": "max_parallel 10 execution, worker leases, structured patch/artifact outputs, budget reservation, and partial-success continuation", + "image": "10 worker lanes running in parallel with leases, budgets, structured outputs, and worker receipts", + }, + { + "id": "artifact-materializer", + "title": "Artifact Materializer", + "focus": "materializing structured worker artifacts into patch bundles without direct worker repo mutation", + "image": "structured worker artifacts flowing into local materializer and patch bundle receipts", + }, + { + "id": "integrator-pool", + "title": "Integrator Pool", + "focus": "2-3 deterministic integrator lanes for patch safety, focused validation, release evidence, rollback, and quarantine", + "image": "three integrator lanes classifying accepted, rejected, and quarantined worker outputs", + }, + { + "id": "ai-review-fallback", + "title": "AI Review Fallback", + "focus": "only-if-needed AI review packets, reviewer budget caps, advisory output, and deterministic receipt conversion", + "image": "AI reviewer call appearing only after deterministic validation cannot classify a conflict", + }, + { + "id": "cost-latency-ledger", + "title": "Cost And Latency Ledger", + "focus": "per-worker timing, queue delay, model usage, hard budget caps, $3-5 marginal target, and 2-3 minute reporting", + "image": "operator cockpit with timing bars, cost ledger, budget cap, and 2-3 minute target", + }, + { + "id": "factory-safe-integrator-bridge", + "title": "Factory Safe Integrator Bridge", + "focus": "feeding accepted workset outputs into Factory/Safe Integrator release packets without auto-merging main", + "image": "workset outputs converging into Safe Integrator branch, rollback plan, and release candidate evidence", + }, + { + "id": "dev-pipeline-ui", + "title": "Dev Pipeline UI", + "focus": "Run Parallel Delivery UI with worker lanes, validator lanes, fallback calls, timing, cost, and receipts", + "image": "Dev Pipeline Studio screen with 10 workers, 3 validators, cost/timing counters, and release readiness", + }, + { + "id": "observability-and-notify", + "title": "Observability And Notify", + "focus": "events, status polling, stuck-run detection, operator notification, and final handoff links", + "image": "status timeline with stuck-run alert, final notification, and evidence links", + }, + { + "id": "benchmark-fixtures", + "title": "Benchmark Fixtures", + "focus": "clean run, shared-file conflict, failed validation, budget block, fallback review, and 10-worker benchmark fixtures", + "image": "fixture matrix showing clean, conflict, validation fail, budget block, and fallback review scenarios", + }, + { + "id": "integration-e2e", + "title": "Integration E2E", + "focus": "one command for plan, execute, integrate, validate, release packet, and machine-readable receipts", + "image": "end-to-end command flow from plan to release packet with validation and rollback receipts", + }, + { + "id": "demo-task", + "title": "Demo Task", + "focus": "small real-looking demo proving 10-lane fanout, deterministic integration, validation, cost ledger, and final evidence", + "image": "demo task view showing all lanes complete and final release packet ready for review", + }, +] + +SELF_IMPROVE_PASS_FOCUS: list[dict[str, str]] = [ + { + "id": "scope-guardrails", + "title": "Scope And Guardrails", + "focus": "Establish the nightly self-improvement objective, autonomy boundary, budget guardrails, scheduler context, nonblocking image policy, and evidence contract.", + }, + { + "id": "architecture", + "title": "Architecture", + "focus": "Turn pass 1 into durable Cento route contracts, artifact schemas, latest mirror behavior, compute routing, and deterministic fallback behavior.", + }, + { + "id": "integration-workset-strategy", + "title": "Integration And Workset Strategy", + "focus": "Turn pass 2 into implementation workset strategy, explicit create-file path policy, migration order, rollback points, and promotion prerequisites.", + }, + { + "id": "validation-promotion-next-cycle", + "title": "Validation, Promotion, And Next Cycle", + "focus": "Turn pass 3 into validation gates, promotion recommendation, repair handling, loop metrics, evidence handoff, and the exact next-night request.", + }, +] + +AGENT_PREFERRED_COMPUTE_POLICY = { + "deterministic_first": True, + "target_spend_usd_max": 10.0, + "hard_spend_usd_max": 20.0, + "optional_model_ceiling": "gpt-4.1-mini", + "backend_success_depends_on_screenshot_generation": False, + "codex_claude_utilization_threshold_percent": 30, + "eligible_work_agent_preference_percent_range": [70, 80], + "eligible_work_agent_preference_target_percent": 75, + "policy": ( + "When Codex/Claude weekly utilization is above 30% and capacity remains usable, " + "prefer Codex/Claude agent lanes over metered OpenAI API for roughly 70-80% of " + "eligible non-API-only follow-up work. Reserve OpenAI API for structured Responses, " + "image generation, ProReq planning, and other API-only behavior." + ), +} + +DEMO_TARGET_PATHS = [ + "docs/agent-run-ledger.md", + "docs/agent-work-coordinator-lane.md", + "docs/agent-work-deliverables-hub.md", + "docs/agent-work-docs-evidence-lane.md", + "docs/agent-work-runtimes.md", + "docs/agent-work-screenshot-runner.md", + "docs/agent-work-story-manifest.md", + "docs/agent-work-validator-lane.md", + "docs/cento-build.md", + "docs/cento-workset.md", +] + +PATCH_SWARM_OBJECTIVE = ( + "Generate many cheap candidate patches across Codex Exec, Claude Code, and " + "OpenAI API workers, validate and rank them deterministically, then let one " + "serialized integration execution hand the winners to Cento Safe Integrator evidence." +) + +PATCH_SWARM_PROVIDERS = ["codex-exec", "claude-code", "api-openai"] +PATCH_SWARM_PROVIDER_ALIASES = { + "codex": "codex-exec", + "codex_exec": "codex-exec", + "codex-exec": "codex-exec", + "claude": "claude-code", + "claude_code": "claude-code", + "claude-code": "claude-code", + "openai": "api-openai", + "openai-api": "api-openai", + "api": "api-openai", + "api-openai": "api-openai", +} +PATCH_SWARM_PROVIDER_RUNTIMES = { + "codex-exec": {"runtime": "local-command", "runtime_profile": "codex-fast", "mutation_mode": "isolated_worktree"}, + "claude-code": {"runtime": "local-command", "runtime_profile": "claude-code-fast", "mutation_mode": "isolated_worktree"}, + "api-openai": {"runtime": "api-openai", "output_schema": "patch_proposal.v1", "mutation_mode": "structured_artifact"}, +} +PATCH_SWARM_API_COST_ESTIMATE_USD = 0.0125 +PATCH_SWARM_DEFAULT_LIVE_HARD_CAP_USD = 25.0 +PATCH_SWARM_LIVE_ADAPTER_ENV = "CENTO_PATCH_SWARM_LIVE_ADAPTERS" +PATCH_SWARM_API_PROFILE = "api-patch-proposal" + +PATCH_SWARM_PROREQ_EXECUTIONS: list[dict[str, Any]] = [ + { + "id": "request-decomposer", + "title": "Request Decomposer", + "focus": "Split the operator objective into patchable tasks, owned paths, protected paths, and validation expectations.", + "owned_paths": ["docs/parallel-ai-delivery-roadmap.md"], + }, + { + "id": "codex-exec-adapter", + "title": "Codex Exec Adapter", + "focus": "Prepare candidate prompts and receipt capture for codex exec local-command workers in isolated worktrees.", + "owned_paths": [".cento/runtimes.yaml"], + }, + { + "id": "claude-code-adapter", + "title": "Claude Code Adapter", + "focus": "Prepare candidate prompts and receipt capture for Claude Code local-command workers in isolated worktrees.", + "owned_paths": ["data/agent-runtimes.json"], + }, + { + "id": "openai-patch-proposal-adapter", + "title": "OpenAI Patch Proposal Adapter", + "focus": "Use structured OpenAI worker artifacts with patch_proposal.v1 and budget receipts.", + "owned_paths": [".cento/api_workers.yaml"], + }, + { + "id": "candidate-normalizer", + "title": "Candidate Normalizer", + "focus": "Normalize command-runtime diffs and structured API artifacts into candidate_patch.v1 receipts.", + "owned_paths": ["scripts/cento_workset.py"], + }, + { + "id": "dedupe-clustering", + "title": "Dedupe And Clustering", + "focus": "Cluster candidates by execution lane, touched path, normalized patch hash, and duplicate intent.", + "owned_paths": ["scripts/parallel_delivery.py"], + }, + { + "id": "deterministic-validator-fanout", + "title": "Deterministic Validator Fanout", + "focus": "Run cheap applyability, ownership, schema, syntax, and focused test gates before any AI review.", + "owned_paths": ["tests/test_parallel_integration_train.py"], + }, + { + "id": "cost-latency-ledger", + "title": "Cost And Latency Ledger", + "focus": "Track candidate cost, latency, duplicate saturation, validation pass rate, and cost per accepted patch.", + "owned_paths": ["scripts/spend_ledger.py"], + }, + { + "id": "dev-pipeline-studio-ui", + "title": "Dev Pipeline Studio UI", + "focus": "Expose provider mix, candidate totals, dedupe clusters, validation, winners, and integration status in the existing UI.", + "owned_paths": ["templates/agent-work-app/app.js"], + }, + { + "id": "autopilot-coordinator-hooks", + "title": "Autopilot Coordinator Hooks", + "focus": "Provide dry-run launch/status/retry/budget-stop artifacts for future Walk Autopilot coordination.", + "owned_paths": ["scripts/walk_autopilot.py"], + }, +] + +PATCH_SWARM_INTEGRATOR = { + "id": "dedicated-integrator", + "title": "Dedicated Patch Swarm Integrator", + "focus": "Consume all ten ProReq execution outputs, select winners, resolve conflicts, and write the Safe Integrator handoff.", +} + + +def now_stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + if isinstance(payload, dict): + return payload + return {} + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def append_jsonl(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") + + +def is_parallel_delivery_run_dir(path: Path) -> bool: + return path.is_dir() and (path / "implementation_manifest.json").exists() + + +def is_parallel_delivery_validatable_run_dir(path: Path) -> bool: + return ( + is_parallel_delivery_run_dir(path) + and (path / "proreq_receipt.json").exists() + and (path / "execution_manifest.json").exists() + ) + + +def is_patch_swarm_fixture_e2e_run_dir(path: Path) -> bool: + return ( + path.is_dir() + and (path / "validation-summary.json").exists() + and (path / "split-plan.json").exists() + and (path / "path-leases.json").exists() + and (path / "integration" / "integration-receipt.json").exists() + ) + + +def latest_patch_swarm_fixture_e2e_run_dir(root: Path | None = None) -> Path | None: + search_root = root or RUNS_ROOT + if not search_root.exists(): + return None + candidates = [ + path.parent + for path in search_root.glob("**/validation-summary.json") + if is_patch_swarm_fixture_e2e_run_dir(path.parent) + ] + if not candidates: + return None + return max(candidates, key=lambda path: path.stat().st_mtime) + + +def selected_patch_swarm_fixture_e2e_run_dir(path: Path) -> Path | None: + if is_patch_swarm_fixture_e2e_run_dir(path): + return path + return latest_patch_swarm_fixture_e2e_run_dir(path) + + +def latest_run_dir() -> Path | None: + if not RUNS_ROOT.exists(): + return None + candidates = [path for path in RUNS_ROOT.iterdir() if is_parallel_delivery_run_dir(path)] + if not candidates: + return None + return max(candidates, key=lambda path: path.stat().st_mtime) + + +def latest_validatable_run_dir() -> Path | None: + if not RUNS_ROOT.exists(): + return None + candidates = [path for path in RUNS_ROOT.iterdir() if is_parallel_delivery_validatable_run_dir(path)] + if not candidates: + return None + return max(candidates, key=lambda path: path.stat().st_mtime) + + +def resolve_run_dir(value: str | None, *, create: bool = False) -> Path: + if value: + path = Path(value) + if not path.is_absolute(): + path = ROOT / path + else: + path = latest_run_dir() if not create else RUNS_ROOT / now_stamp() + if path is None: + path = RUNS_ROOT / now_stamp() + if create: + path.mkdir(parents=True, exist_ok=True) + return path + + +def resolve_validation_run_dir(value: str | None) -> Path: + if value: + return resolve_run_dir(value) + return latest_validatable_run_dir() or latest_run_dir() or (latest_patch_swarm_fixture_e2e_run_dir() or RUNS_ROOT / now_stamp()) + + +@contextmanager +def scoped_env(updates: dict[str, str]) -> Iterator[None]: + old_values = {key: os.environ.get(key) for key in updates} + try: + for key, value in updates.items(): + os.environ[key] = value + yield + finally: + for key, value in old_values.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def pass_prompt(workstream: dict[str, str]) -> str: + return ( + f"{BASE_VISION}\n\n" + f"VP-level implementation workstream: {workstream['title']}.\n" + f"Focus: {workstream['focus']}.\n\n" + "Return implementation-ready guidance: manifests, interfaces, deterministic validation gates, " + "fallback triggers, receipts, demo evidence, rollout risks, and acceptance criteria. Keep the " + "output aligned with existing Cento Hard ProReq, Workset, Factory, and Safe Integrator contracts." + ) + + +def image_task(workstream: dict[str, str]) -> str: + return ( + "Create a ChatGPT image prompt for a dense Cento operator UI screenshot. " + f"Subject: {workstream['image']}. The UI must show requirements splitting, 10 worker lanes, " + "2-3 integrator/validator lanes, deterministic gates, AI fallback only when needed, cost/timing " + "counters, quarantine/release evidence, and final handoff." + ) + + +def pipeline_payload(operator_prompt: str, reference_screenshot: str = "") -> dict[str, Any]: + screenshot_input: dict[str, Any] = {"id": "ui-screenshot-request", "kind": "image", "source": "auto"} + if reference_screenshot: + screenshot_input["image_refs"] = [reference_screenshot] + screenshot_input["image_notes"] = "Use this as visual style context for the requested parallel delivery UI image." + return { + "schema_version": app.PIPELINE_RUN_SCHEMA_VERSION, + "project_id": app.HARD_PROREQ_PROJECT_ID, + "template_id": app.HARD_PROREQ_TEMPLATE_ID, + "inputs": [ + {"id": "operator-thoughts", "kind": "questionnaire", "source": "user", "answer": operator_prompt}, + {"id": "generated-cento-context", "kind": "path", "source": "auto"}, + screenshot_input, + {"id": "pro-backend-schema", "kind": "details", "source": "auto"}, + {"id": "backend-work-handoff", "kind": "evidence", "source": "auto"}, + ], + } + + +def pipeline_run_payload(run_id: str) -> dict[str, Any]: + run_path = PIPELINE_ROOT / "execution" / "runs" / f"{run_id}.json" + return read_json(run_path) or app.dev_pipeline_artifact_json(app.DEV_PIPELINE_STUDIO_ROOT, "execution/execution_run.json") + + +def hard_proreq_root(run_id: str) -> Path: + return PIPELINE_ROOT / "execution" / "hard-proreq" / run_id + + +def summarize_hard_proreq(run_id: str) -> dict[str, Any]: + root = hard_proreq_root(run_id) + backend = read_json(root / "backend_work_manifest.json") + pro_response = read_json(root / "pro_backend_response.json") + image_response = read_json(root / "image_generation_response.json") + image_request = read_json(root / "image_generation_request.json") + story_index = read_json(root / "story_index.json") + image_error = "" + if isinstance(image_response.get("response"), dict): + error_payload = image_response["response"].get("error") + if isinstance(error_payload, dict): + image_error = str(error_payload.get("message") or "") + return { + "artifact_root": rel(root), + "story_count": int(backend.get("story_count") or story_index.get("story_count") or 0), + "story_index": str(backend.get("story_index") or ""), + "parallel_patch_workset": str(backend.get("parallel_patch_workset") or ""), + "integration_policy": str(backend.get("integration_policy") or ""), + "integration_plan": rel(root / "integration_plan.json"), + "validation_plan": rel(root / "validation_plan.json"), + "pro_backend_request": rel(root / "pro_backend_request.json"), + "pro_response_status": str(pro_response.get("status") or ""), + "pro_skip_code": str(pro_response.get("skip_code") or ""), + "pro_model": str(pro_response.get("model") or ""), + "image_request": rel(root / "image_generation_request.json"), + "image_model": str(image_response.get("model") or image_request.get("model") or ""), + "image_response_status": str(image_response.get("status") or ""), + "image_skip_code": str(image_response.get("skip_code") or ""), + "image_error": image_error, + "generated_image": str(image_response.get("output_image") or ""), + "evidence": rel(root / "hard_proreq_evidence.json"), + } + + +def wait_for_pipeline(run_id: str, timeout_seconds: int, poll_seconds: float) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + payload = pipeline_run_payload(run_id) + status = str(payload.get("status") or "") + if status in {"completed", "failed", "blocked", "rejected"}: + return payload + time.sleep(poll_seconds) + payload = pipeline_run_payload(run_id) + payload["observed_status"] = str(payload.get("status") or "") + payload["status"] = "timeout" + payload["timeout_seconds"] = timeout_seconds + return payload + + +def run_workset_check(workset_path: str, *, runtime: str = "", allow_creates: bool = False) -> dict[str, Any]: + if not workset_path: + return {"status": "missing", "exit_code": 1, "stdout": "", "stderr": "missing workset path"} + command = ["./scripts/cento.sh", "workset", "check", workset_path] + if runtime: + command.extend(["--runtime", runtime]) + if allow_creates: + command.append("--allow-creates") + result = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return { + "status": "passed" if result.returncode == 0 else "failed", + "command": command, + "exit_code": result.returncode, + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + } + + +def train_latest_run_dir() -> Path | None: + if not TRAIN_RUNS_ROOT.exists(): + return None + candidates = [path for path in TRAIN_RUNS_ROOT.iterdir() if path.is_dir() and (path / "train_manifest.json").exists()] + return max(candidates, key=lambda path: path.stat().st_mtime) if candidates else None + + +def resolve_train_run_dir(value: str | None, *, create: bool = False) -> Path: + if value: + path = Path(value) + if not path.is_absolute() and ("/" not in value and "\\" not in value): + path = TRAIN_RUNS_ROOT / value + elif not path.is_absolute(): + path = ROOT / path + else: + path = TRAIN_RUNS_ROOT / now_stamp() if create else train_latest_run_dir() + if path is None: + path = TRAIN_RUNS_ROOT / now_stamp() + if create: + path.mkdir(parents=True, exist_ok=True) + return path + + +def train_event(run_dir: Path, event: str, payload: dict[str, Any]) -> None: + append_jsonl(run_dir / "events.ndjson", {"written_at": now_iso(), "event": event, **payload}) + + +def text_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def train_normalize_path(value: str) -> str: + return str(value).strip().strip("/") + + +def train_paths_conflict(left: str, right: str) -> bool: + left_norm = train_normalize_path(left) + right_norm = train_normalize_path(right) + if not left_norm or not right_norm: + return False + return left_norm == right_norm or left_norm.startswith(right_norm + "/") or right_norm.startswith(left_norm + "/") + + +def train_task_id(task: dict[str, Any], index: int) -> str: + return str(task.get("id") or task.get("worker_id") or f"task-{index:02d}") + + +def train_path_blockers(tasks: list[dict[str, Any]]) -> dict[str, list[str]]: + blockers: dict[str, list[str]] = {} + path_owner: list[tuple[str, str]] = [] + for index, task in enumerate(tasks, start=1): + task_id = train_task_id(task, index) + write_paths = text_list(task.get("write_paths")) + if not write_paths: + blockers.setdefault(task_id, []).append("missing write_paths") + for path in write_paths: + if Path(path).is_absolute(): + blockers.setdefault(task_id, []).append(f"absolute write path is not allowed: {path}") + if "*" in path or "?" in path or "[" in path: + blockers.setdefault(task_id, []).append(f"glob write path is not allowed: {path}") + for other_task_id, other_path in path_owner: + if train_paths_conflict(path, other_path): + blockers.setdefault(task_id, []).append(f"write path conflicts with {other_task_id}: {path}") + blockers.setdefault(other_task_id, []).append(f"write path conflicts with {task_id}: {other_path}") + path_owner.append((task_id, path)) + return blockers + + +def train_dependency_order(tasks: list[dict[str, Any]]) -> tuple[list[str], dict[str, list[str]]]: + ids = [train_task_id(task, index) for index, task in enumerate(tasks, start=1)] + known = set(ids) + deps = { + task_id: [dep for dep in text_list(task.get("depends_on")) if dep] + for task_id, task in zip(ids, tasks) + } + blockers: dict[str, list[str]] = {} + for task_id, dep_ids in deps.items(): + missing = [dep for dep in dep_ids if dep not in known] + if missing: + blockers.setdefault(task_id, []).append(f"missing dependency: {', '.join(missing)}") + + remaining = list(ids) + ordered: list[str] = [] + while remaining: + progressed = False + for task_id in list(remaining): + if all(dep in ordered or dep not in known for dep in deps.get(task_id, [])): + ordered.append(task_id) + remaining.remove(task_id) + progressed = True + if not progressed: + for task_id in remaining: + blockers.setdefault(task_id, []).append("dependency cycle or unresolved dependency") + ordered.extend(remaining) + break + return ordered, blockers + + +def build_train_artifacts(source_workset: Path, run_dir: Path, *, max_parallel: int) -> dict[str, Any]: + source_payload = read_json(source_workset) + copied_workset = dict(source_payload) + copied_workset.setdefault("max_parallel", max_parallel) + workset_path = run_dir / "workset.json" + write_json(workset_path, copied_workset) + check = run_workset_check(rel(workset_path)) + tasks = [item for item in copied_workset.get("tasks") or [] if isinstance(item, dict)] + path_blockers = train_path_blockers(tasks) + order, dependency_blockers = train_dependency_order(tasks) + blockers: dict[str, list[str]] = {} + for source in (path_blockers, dependency_blockers): + for task_id, reasons in source.items(): + blockers.setdefault(task_id, []).extend(reasons) + deps = {train_task_id(task, index): text_list(task.get("depends_on")) for index, task in enumerate(tasks, start=1)} + changed = True + while changed: + changed = False + blocked_ids = {task_id for task_id, reasons in blockers.items() if reasons} + for task_id, dep_ids in deps.items(): + for dep_id in dep_ids: + if dep_id in blocked_ids: + reason = f"blocked dependency: {dep_id}" + reasons = blockers.setdefault(task_id, []) + if reason not in reasons: + reasons.append(reason) + changed = True + + shards: list[dict[str, Any]] = [] + for index, task in enumerate(tasks, start=1): + task_id = train_task_id(task, index) + reasons = sorted(set(blockers.get(task_id) or [])) + if check.get("status") != "passed" and not reasons: + reasons = ["workset check failed"] + shards.append( + { + "task_id": task_id, + "worker_id": str(task.get("worker_id") or task_id), + "write_paths": text_list(task.get("write_paths")), + "depends_on": text_list(task.get("depends_on")), + "status": "blocked" if reasons else "planned", + "blockers": reasons, + "integration_order": order.index(task_id) + 1 if task_id in order else index, + } + ) + shards_by_id = {str(item["task_id"]): item for item in shards} + queue_items = [] + for task_id in order: + shard = shards_by_id.get(task_id) + if not shard: + continue + queue_items.append( + { + "task_id": task_id, + "worker_id": shard["worker_id"], + "write_paths": shard["write_paths"], + "depends_on": shard["depends_on"], + "integration_order": shard["integration_order"], + "status": "blocked" if shard["status"] == "blocked" else ("waiting" if shard["depends_on"] else "ready_for_worker"), + "blockers": shard["blockers"], + "apply": False, + } + ) + status = "blocked" if check.get("status") != "passed" or any(item["status"] == "blocked" for item in shards) else "planned" + manifest = { + "schema_version": SCHEMA_TRAIN, + "id": run_dir.name, + "created_at": now_iso(), + "mode": "dry-run", + "status": status, + "max_parallel": max_parallel, + "source_workset": rel(source_workset), + "workset": rel(workset_path), + "workset_check": rel(run_dir / "workset_check.json"), + "shards": shards, + "integration_policy": {"strategy": "sequential", "apply": False, "validate_each": True}, + "artifacts": { + "integration_queue": rel(run_dir / "integration_queue.json"), + "receipt": rel(run_dir / "train_receipt.json"), + "events": rel(run_dir / "events.ndjson"), + "decision_report": rel(run_dir / "decision_report.md"), + }, + } + queue = { + "schema_version": SCHEMA_TRAIN_QUEUE, + "run_id": run_dir.name, + "written_at": now_iso(), + "strategy": "sequential", + "apply": False, + "items": queue_items, + } + write_json(run_dir / "workset_check.json", check) + write_json(run_dir / "train_manifest.json", manifest) + write_json(run_dir / "integration_queue.json", queue) + write_train_report(run_dir, manifest, queue, None) + train_event(run_dir, "train_planned", {"status": status, "task_count": len(tasks), "max_parallel": max_parallel}) + return manifest + + +def train_receipt_payload(run_dir: Path, manifest: dict[str, Any], queue: dict[str, Any], *, status: str) -> dict[str, Any]: + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + counts = {} + for item in items: + item_status = str(item.get("status") or "unknown") + counts[item_status] = counts.get(item_status, 0) + 1 + return { + "schema_version": SCHEMA_TRAIN_RECEIPT, + "run_id": run_dir.name, + "written_at": now_iso(), + "status": status, + "mode": "dry-run", + "max_parallel": manifest.get("max_parallel"), + "apply": False, + "task_status_counts": dict(sorted(counts.items())), + "train_manifest": rel(run_dir / "train_manifest.json"), + "integration_queue": rel(run_dir / "integration_queue.json"), + } + + +def train_workset_execute_command( + manifest: dict[str, Any], + *, + runtime: str, + runtime_profile: str = "", + api_profile: str = "", + api_config: str = "", + budget_usd: float | None = None, + max_budget_usd: float | None = None, + validation: str = "", + worker_timeout: int | None = None, + retry_attempts: int | None = None, + fixture_case: str = "valid", + allow_dirty_owned: bool = False, + allow_creates: bool = False, +) -> list[str]: + workset_path = str(manifest.get("workset") or "") + if not workset_path: + raise ValueError("train manifest is missing workset path") + effective_runtime = runtime or "fixture" + if effective_runtime == "api-openai" and (budget_usd is None or max_budget_usd is None): + raise ValueError("train workset api-openai execution requires --budget-usd and --max-budget-usd") + command = [ + "./scripts/cento.sh", + "workset", + "execute", + workset_path, + "--max-parallel", + str(int(manifest.get("max_parallel") or 1)), + "--runtime", + effective_runtime, + "--integrate", + "sequential", + ] + if runtime_profile: + command.extend(["--runtime-profile", runtime_profile]) + if validation: + command.extend(["--validation", validation]) + if worker_timeout is not None and worker_timeout > 0: + command.extend(["--worker-timeout", str(worker_timeout)]) + if retry_attempts is not None and retry_attempts >= 0: + command.extend(["--retry-attempts", str(retry_attempts)]) + if effective_runtime == "fixture": + command.extend(["--fixture-case", fixture_case or "valid"]) + if effective_runtime == "api-openai": + if api_profile: + command.extend(["--api-profile", api_profile]) + if api_config: + command.extend(["--api-config", api_config]) + command.extend(["--budget-usd", f"{float(budget_usd):.6f}", "--max-budget-usd", f"{float(max_budget_usd):.6f}"]) + if allow_dirty_owned: + command.append("--allow-dirty-owned") + if allow_creates: + command.append("--allow-creates") + command.append("--json") + return command + + +def execute_train_workset( + run_dir: Path, + *, + runtime: str = "fixture", + runtime_profile: str = "", + api_profile: str = "", + api_config: str = "", + budget_usd: float | None = None, + max_budget_usd: float | None = None, + validation: str = "smoke", + worker_timeout: int | None = None, + retry_attempts: int | None = None, + fixture_case: str = "valid", + allow_dirty_owned: bool = False, + allow_creates: bool = False, +) -> dict[str, Any]: + manifest = read_json(run_dir / "train_manifest.json") + queue = read_json(run_dir / "integration_queue.json") + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + blocked = [str(item.get("task_id") or "") for item in items if item.get("status") == "blocked"] + if blocked: + receipt = train_receipt_payload(run_dir, manifest, queue, status="blocked") + receipt.update( + { + "workset_pipeline": True, + "workset_skipped": True, + "errors": [f"blocked train queue items: {', '.join(sorted(blocked))}"], + } + ) + write_json(run_dir / "train_receipt.json", receipt) + write_train_report(run_dir, manifest, queue, receipt) + train_event(run_dir, "train_workset_skipped", {"status": "blocked", "blocked_items": sorted(blocked)}) + return receipt + + try: + command = train_workset_execute_command( + manifest, + runtime=runtime, + runtime_profile=runtime_profile, + api_profile=api_profile, + api_config=api_config, + budget_usd=budget_usd, + max_budget_usd=max_budget_usd, + validation=validation, + worker_timeout=worker_timeout, + retry_attempts=retry_attempts, + fixture_case=fixture_case, + allow_dirty_owned=allow_dirty_owned, + allow_creates=allow_creates, + ) + except ValueError as exc: + receipt = train_receipt_payload(run_dir, manifest, queue, status="workset_rejected") + receipt.update({"workset_pipeline": True, "errors": [str(exc)]}) + write_json(run_dir / "train_receipt.json", receipt) + write_train_report(run_dir, manifest, queue, receipt) + train_event(run_dir, "train_workset_rejected", {"error": str(exc)}) + return receipt + + command_record = { + "schema_version": "cento.parallel_integration_train.workset_execute_command.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "command": command, + "runtime": runtime or "fixture", + "apply": False, + "integration": "sequential", + } + write_json(run_dir / "workset_execute_command.json", command_record) + train_event(run_dir, "train_workset_execute_started", {"runtime": runtime or "fixture", "command": " ".join(shlex.quote(part) for part in command)}) + + result = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + try: + workset_result = json.loads(result.stdout or "{}") + except json.JSONDecodeError: + workset_result = {} + write_json( + run_dir / "workset_execute_result.json", + { + "schema_version": "cento.parallel_integration_train.workset_execute_result.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "exit_code": result.returncode, + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + "payload": workset_result, + }, + ) + + task_statuses = workset_result.get("task_statuses") if isinstance(workset_result.get("task_statuses"), dict) else {} + success_statuses = {"accepted", "applied", "completed"} + for item in items: + if item.get("status") == "blocked": + continue + task_id = str(item.get("task_id") or "") + task_status = str(task_statuses.get(task_id) or "") + item["workset_task_status"] = task_status or "missing" + item["workset_receipt"] = str(workset_result.get("workset_receipt") or "") + item["workset_dir"] = str(workset_result.get("workset_dir") or "") + if task_status in success_statuses: + item["status"] = "workset_integrated" + elif task_status: + item["status"] = "blocked" + reason = f"workset task status: {task_status}" + blockers = item.setdefault("blockers", []) + if reason not in blockers: + blockers.append(reason) + else: + item["status"] = "blocked" + blockers = item.setdefault("blockers", []) + if "workset task status missing" not in blockers: + blockers.append("workset task status missing") + + queue["written_at"] = now_iso() + queue["workset_pipeline"] = True + queue["workset_execute_result"] = rel(run_dir / "workset_execute_result.json") + write_json(run_dir / "integration_queue.json", queue) + status = "workset_completed" if result.returncode == 0 and workset_result.get("status") == "completed" and all(item.get("status") == "workset_integrated" for item in items) else "workset_failed" + receipt = train_receipt_payload(run_dir, manifest, queue, status=status) + receipt.update( + { + "workset_pipeline": True, + "workset_runtime": runtime or "fixture", + "workset_status": str(workset_result.get("status") or ""), + "workset_exit_code": result.returncode, + "workset_dir": str(workset_result.get("workset_dir") or ""), + "workset_receipt": str(workset_result.get("workset_receipt") or ""), + "workset_total_cost_usd": workset_result.get("total_cost_usd", 0.0), + "workset_execute_command": rel(run_dir / "workset_execute_command.json"), + "workset_execute_result": rel(run_dir / "workset_execute_result.json"), + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + } + ) + write_json(run_dir / "train_receipt.json", receipt) + write_train_report(run_dir, manifest, queue, receipt) + train_event( + run_dir, + "train_workset_execute_completed", + {"status": status, "exit_code": result.returncode, "workset_status": str(workset_result.get("status") or "")}, + ) + return receipt + + +def resolve_cento_path(value: str | Path) -> Path: + path = Path(str(value)) + return path if path.is_absolute() else ROOT / path + + +def train_factory_run_dir(run_dir: Path) -> Path: + return FACTORY_RUNS_ROOT / f"parallel-train-{run_dir.name}" + + +def workset_receipt_payload(receipt: dict[str, Any]) -> tuple[Path | None, dict[str, Any]]: + value = str(receipt.get("workset_receipt") or "") + if not value: + return None, {} + path = resolve_cento_path(value) + return path, read_json(path) + + +def workset_task_lookup(workset_receipt: dict[str, Any]) -> dict[str, dict[str, Any]]: + raw = workset_receipt.get("tasks") if isinstance(workset_receipt.get("tasks"), dict) else {} + return {str(task_id): task for task_id, task in raw.items() if isinstance(task, dict)} + + +def train_factory_plan_payload(run_dir: Path, factory_run_dir: Path, queue: dict[str, Any], workset_receipt: dict[str, Any]) -> dict[str, Any]: + manifest = read_json(run_dir / "train_manifest.json") + workset = read_json(resolve_cento_path(str(manifest.get("workset") or ""))) + source_tasks = { + train_task_id(task, index): task + for index, task in enumerate([item for item in workset.get("tasks") or [] if isinstance(item, dict)], start=1) + } + items = sorted(queue.get("items") if isinstance(queue.get("items"), list) else [], key=lambda item: int(item.get("integration_order") or 0)) + tasks: list[dict[str, Any]] = [] + for item in items: + task_id = str(item.get("task_id") or "") + source_task = source_tasks.get(task_id, {}) + write_paths = text_list(item.get("write_paths")) + task_title = str(source_task.get("title") or source_task.get("task") or task_id) + tasks.append( + { + "id": task_id, + "title": f"Promote train task: {task_title}", + "lane": "builder", + "node": "linux", + "owned_scope": write_paths, + "goal": f"Promote accepted Workset output for train task `{task_id}` into the Factory Safe Integrator handoff.", + "expected_outputs": [ + { + "path": rel(factory_run_dir / "patches" / task_id / "patch.json"), + "description": "Factory-collected patch bundle converted from the Workset receipt.", + } + ], + "validation_commands": [f"python3 -m json.tool {shlex.quote(str(factory_run_dir / 'patches' / task_id / 'validation-result.json'))}"], + "no_model_eligible": True, + "risk": "low", + "dependencies": text_list(item.get("depends_on")), + } + ) + return { + "schema_version": "factory-plan/v1", + "run_id": factory_run_dir.name, + "request": { + "raw": f"Promote parallel train run {run_dir.name} into Factory Safe Integrator.", + "normalized_goal": "Convert accepted parallel Workset outputs into a Factory apply plan and release-candidate handoff.", + }, + "package": "parallel-train-promotion", + "mode": "plan_only", + "risk": "low", + "budget": { + "ai_call_budget": 0, + "estimated_cost_usd": float(workset_receipt.get("total_cost_usd") or 0.0), + "strong_model_calls_allowed": 0, + "cheap_worker_calls_allowed": 0, + }, + "shared_paths": [], + "tasks": tasks, + "integration": { + "strategy": "safe_integrator_from_parallel_train", + "merge_order": [str(item.get("task_id") or "") for item in items if item.get("task_id")], + "required_docs": [], + }, + "validation": { + "minimum_tier": "tier0", + "requires_screenshots": False, + "requires_api_smoke": False, + "requires_human_review": True, + }, + "evidence": { + "run_dir": rel(factory_run_dir), + "summary": rel(factory_run_dir / "summary.md"), + }, + "created_at": now_iso(), + "source_train": { + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "train_receipt": rel(run_dir / "train_receipt.json"), + "workset_receipt": str(workset_receipt.get("workset_receipt") or ""), + }, + } + + +def train_promotion_rows(queue: dict[str, Any], workset_receipt: dict[str, Any]) -> list[dict[str, Any]]: + tasks = workset_task_lookup(workset_receipt) + rows: list[dict[str, Any]] = [] + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + for item in items: + task_id = str(item.get("task_id") or "") + task = tasks.get(task_id, {}) + reasons: list[str] = [] + if item.get("status") != "workset_integrated": + reasons.append(f"train queue status is {item.get('status') or 'unknown'}") + if str(task.get("status") or "") not in {"accepted", "applied", "completed"}: + reasons.append(f"workset task status is {task.get('status') or 'missing'}") + patch_bundle = str(task.get("patch_bundle") or "") + if not patch_bundle or not resolve_cento_path(patch_bundle).exists(): + reasons.append("patch bundle missing") + validation_receipt = read_json(resolve_cento_path(str(task.get("validation_receipt") or ""))) if task.get("validation_receipt") else {} + if validation_receipt.get("status") not in {"passed", "pass", "ok"}: + reasons.append(f"validation status is {validation_receipt.get('status') or 'missing'}") + rows.append( + { + "task_id": task_id, + "status": "accepted" if not reasons else "blocked", + "reasons": reasons, + "workset_task_status": task.get("status", ""), + "patch_bundle": patch_bundle, + "validation_receipt": str(task.get("validation_receipt") or ""), + "changed_paths": text_list(task.get("changed_paths")), + } + ) + return rows + + +def copy_workset_outputs_to_factory(factory_run_dir: Path, queue: dict[str, Any], workset_receipt: dict[str, Any]) -> None: + tasks = workset_task_lookup(workset_receipt) + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + for item in items: + task_id = str(item.get("task_id") or "") + task = tasks.get(task_id, {}) + task_dir = factory_run_dir / "tasks" / task_id + task_dir.mkdir(parents=True, exist_ok=True) + patch_bundle = read_json(resolve_cento_path(str(task.get("patch_bundle") or ""))) if task.get("patch_bundle") else {} + patch_file = str(task.get("patch") or patch_bundle.get("patch_file") or "") + if patch_file and resolve_cento_path(patch_file).exists(): + shutil.copy2(resolve_cento_path(patch_file), task_dir / "patch.diff") + changed_files = text_list(patch_bundle.get("touched_paths")) or text_list(task.get("changed_paths")) + (task_dir / "changed-files.txt").write_text("\n".join(changed_files) + ("\n" if changed_files else ""), encoding="utf-8") + diffstat = "\n".join(changed_files) if changed_files else "Patch changed-files unavailable." + (task_dir / "diffstat.txt").write_text(diffstat + "\n", encoding="utf-8") + validation_receipt = read_json(resolve_cento_path(str(task.get("validation_receipt") or ""))) if task.get("validation_receipt") else {} + validation_status = str(validation_receipt.get("status") or "unknown") + write_json( + task_dir / "validation-result.json", + { + "schema_version": "factory-validation-result/v1", + "status": "passed" if validation_status in {"passed", "pass", "ok"} else validation_status, + "source_validation_receipt": str(task.get("validation_receipt") or ""), + "ai_calls_used": 0, + "estimated_ai_cost_usd": 0, + "generated_at": now_iso(), + }, + ) + handoff = [ + f"# {task_id} Train Promotion Handoff", + "", + f"- Workset status: `{task.get('status') or 'unknown'}`", + f"- Patch bundle: `{task.get('patch_bundle') or ''}`", + f"- Validation receipt: `{task.get('validation_receipt') or ''}`", + ] + (task_dir / "handoff.md").write_text("\n".join(handoff) + "\n", encoding="utf-8") + evidence_dir = task_dir / "evidence" + evidence_dir.mkdir(exist_ok=True) + write_json( + evidence_dir / "workset-task.json", + { + "schema_version": "cento.parallel_integration_train.factory_task_evidence.v1", + "task_id": task_id, + "workset_task": task, + "source_workset_receipt": str(workset_receipt.get("workset_receipt") or ""), + "written_at": now_iso(), + }, + ) + + +def write_promotion_report(run_dir: Path, decision: dict[str, Any]) -> None: + lines = [ + "# Train Promotion Decision", + "", + f"- Train: `{run_dir.name}`", + f"- Status: `{decision.get('status')}`", + f"- Decision: `{decision.get('decision')}`", + f"- Factory run: `{decision.get('factory_run_dir') or '-'}`", + f"- Candidates: `{decision.get('candidate_count', 0)}`", + f"- Rejected: `{decision.get('rejected_count', 0)}`", + "", + "## Artifacts", + "", + f"- Promotion manifest: `{rel(run_dir / 'promotion_manifest.json')}`", + f"- Factory handoff: `{rel(run_dir / 'factory_handoff.json')}`", + f"- Promotion decision: `{rel(run_dir / 'promotion_decision.json')}`", + ] + if decision.get("release_candidate"): + lines.append(f"- Release candidate: `{decision.get('release_candidate')}`") + blockers = decision.get("blockers") if isinstance(decision.get("blockers"), list) else [] + if blockers: + lines.extend(["", "## Blockers", ""]) + lines.extend(f"- {item}" for item in blockers) + (run_dir / "promotion_decision.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def promote_train_run( + run_dir: Path, + *, + apply: bool = False, + validate_each: bool = False, + branch: str = "", + worktree: str = "", + limit: int = 0, +) -> dict[str, Any]: + queue = read_json(run_dir / "integration_queue.json") + receipt = read_json(run_dir / "train_receipt.json") + workset_receipt_path, workset_receipt = workset_receipt_payload(receipt) + blockers: list[str] = [] + if receipt.get("status") != "workset_completed": + blockers.append(f"train status is {receipt.get('status') or 'unknown'}") + if not workset_receipt: + blockers.append("workset receipt missing") + rows = train_promotion_rows(queue, workset_receipt) if workset_receipt else [] + + factory_run_dir = train_factory_run_dir(run_dir) + promotion_manifest = { + "schema_version": "cento.parallel_integration_train.promotion_manifest.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "mode": "apply" if apply else "dry-run", + "train_run_dir": rel(run_dir), + "train_receipt": rel(run_dir / "train_receipt.json"), + "workset_receipt": rel(workset_receipt_path) if workset_receipt_path else "", + "factory_run_dir": rel(factory_run_dir), + "tasks": rows, + } + write_json(run_dir / "promotion_manifest.json", promotion_manifest) + + factory_handoff: dict[str, Any] = { + "schema_version": "cento.parallel_integration_train.factory_handoff.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "factory_run_dir": rel(factory_run_dir), + "promotion_manifest": rel(run_dir / "promotion_manifest.json"), + } + apply_plan: dict[str, Any] = {} + apply_result: dict[str, Any] = {} + integrated_validation: dict[str, Any] = {} + release: dict[str, Any] = {} + if not blockers: + factory_run_dir.mkdir(parents=True, exist_ok=True) + factory_plan = train_factory_plan_payload(run_dir, factory_run_dir, queue, workset_receipt) + write_json(factory_run_dir / "factory-plan.json", factory_plan) + (factory_run_dir / "summary.md").write_text(f"# Parallel Train Promotion\n\nSource train: `{run_dir.name}`\n", encoding="utf-8") + factory_tool.materialize_run(factory_run_dir) + factory_dispatch.generate_queue(factory_run_dir) + copy_workset_outputs_to_factory(factory_run_dir, queue, workset_receipt) + patch_collection = factory_dispatch.collect_patches(factory_run_dir) + apply_plan = factory_integrator.create_apply_plan(factory_run_dir) + factory_integrator.update_integration_state(factory_run_dir) + factory_handoff.update( + { + "factory_plan": rel(factory_run_dir / "factory-plan.json"), + "factory_queue": rel(factory_run_dir / "queue" / "queue.json"), + "patch_collection": rel(factory_run_dir / "patch-collection-summary.json"), + "apply_plan": rel(factory_run_dir / "integration" / "apply-plan.json"), + "patch_collection_status": patch_collection.get("schema_version", ""), + } + ) + if apply: + factory_integrator.prepare_branch(factory_run_dir, branch=branch, worktree=worktree or None) + apply_result = factory_integrator.apply_patches(factory_run_dir, worktree=worktree or None, branch=branch, limit=limit, validate_each=validate_each) + integrated_validation = factory_integrator.validate_integrated(factory_run_dir) + release = factory_integrator.render_release_candidate(factory_run_dir) + factory_handoff.update( + { + "apply_result": rel(factory_run_dir / "integration" / "applied-patches.json"), + "integrated_validation": rel(factory_run_dir / "integration" / "integrated-validation.json"), + "release_candidate": release.get("release_candidate", ""), + } + ) + + candidate_count = len(apply_plan.get("candidates") or []) + rejected_count = len(apply_plan.get("rejected") or []) + len(blockers) + if blockers: + decision_value = "blocked" + status = "blocked" + elif apply: + decision_value = "release_candidate_ready" if integrated_validation.get("decision") == "approve" else "apply_blocked" + status = "completed" if decision_value == "release_candidate_ready" else "blocked" + elif candidate_count and rejected_count == 0: + decision_value = "ready_for_apply" + status = "planned" + elif candidate_count: + decision_value = "partial_ready_for_apply" + status = "planned" + else: + decision_value = "blocked" + status = "blocked" + decision = { + "schema_version": "cento.parallel_integration_train.promotion_decision.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "status": status, + "decision": decision_value, + "mode": "apply" if apply else "dry-run", + "factory_run_dir": rel(factory_run_dir) if not blockers else "", + "candidate_count": candidate_count, + "rejected_count": rejected_count, + "blockers": blockers, + "blocked_tasks": [row for row in rows if row.get("status") != "accepted"], + "promotion_manifest": rel(run_dir / "promotion_manifest.json"), + "factory_handoff": rel(run_dir / "factory_handoff.json"), + "apply_plan": rel(factory_run_dir / "integration" / "apply-plan.json") if apply_plan else "", + "release_candidate": release.get("release_candidate", ""), + } + write_json(run_dir / "factory_handoff.json", factory_handoff) + write_json(run_dir / "promotion_decision.json", decision) + write_promotion_report(run_dir, decision) + train_event(run_dir, "train_promoted", {"status": status, "decision": decision_value, "factory_run_dir": decision["factory_run_dir"]}) + return decision + + +def write_train_report(run_dir: Path, manifest: dict[str, Any], queue: dict[str, Any], receipt: dict[str, Any] | None) -> None: + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + lines = [ + "# Parallel Integration Train", + "", + f"- Run: `{run_dir.name}`", + f"- Status: `{receipt.get('status') if receipt else manifest.get('status')}`", + f"- Mode: `{manifest.get('mode')}`", + f"- Max parallel: `{manifest.get('max_parallel')}`", + f"- Workset: `{manifest.get('workset')}`", + f"- Apply: `false`", + ] + if receipt and receipt.get("workset_pipeline"): + lines.extend( + [ + f"- Workset pipeline: `{receipt.get('workset_status') or 'skipped'}`", + f"- Workset receipt: `{receipt.get('workset_receipt') or '-'}`", + ] + ) + lines.extend(["", "## Queue", ""]) + if items: + for item in items: + blockers = ", ".join(str(reason) for reason in item.get("blockers") or []) or "-" + lines.append(f"- `{item.get('status')}` order={item.get('integration_order')} task=`{item.get('task_id')}` worker=`{item.get('worker_id')}` blockers={blockers}") + else: + lines.append("- No queue items.") + lines.extend( + [ + "", + "## Artifacts", + "", + f"- Manifest: `{rel(run_dir / 'train_manifest.json')}`", + f"- Queue: `{rel(run_dir / 'integration_queue.json')}`", + f"- Receipt: `{rel(run_dir / 'train_receipt.json')}`", + f"- Events: `{rel(run_dir / 'events.ndjson')}`", + ] + ) + if (run_dir / "workset_execute_command.json").exists(): + lines.append(f"- Workset execute command: `{rel(run_dir / 'workset_execute_command.json')}`") + if (run_dir / "workset_execute_result.json").exists(): + lines.append(f"- Workset execute result: `{rel(run_dir / 'workset_execute_result.json')}`") + lines.extend( + [ + "", + "## Safety", + "", + "- Worker readiness can be simulated or delegated to `cento workset execute`.", + "- Integration remains sequential and dry-run by default.", + "- Train workset execution does not pass `--apply`.", + "", + ] + ) + (run_dir / "decision_report.md").write_text("\n".join(lines), encoding="utf-8") + + +def simulate_train_workers(run_dir: Path) -> dict[str, Any]: + manifest = read_json(run_dir / "train_manifest.json") + queue = read_json(run_dir / "integration_queue.json") + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + for item in items: + if item.get("status") == "blocked": + continue + item["status"] = "ready_for_integration" + worker_dir = run_dir / "workers" / str(item.get("worker_id") or item.get("task_id")) + write_json( + worker_dir / "worker_receipt.json", + { + "schema_version": "cento.parallel_integration_train.worker_receipt.v1", + "run_id": run_dir.name, + "task_id": item.get("task_id"), + "worker_id": item.get("worker_id"), + "status": "simulated_ready", + "apply": False, + "written_at": now_iso(), + }, + ) + queue["written_at"] = now_iso() + write_json(run_dir / "integration_queue.json", queue) + status = "blocked" if any(item.get("status") == "blocked" for item in items) else "workers_simulated" + receipt = train_receipt_payload(run_dir, manifest, queue, status=status) + write_json(run_dir / "train_receipt.json", receipt) + write_train_report(run_dir, manifest, queue, receipt) + train_event(run_dir, "train_workers_simulated", {"status": status}) + return receipt + + +def dry_run_train_integration(run_dir: Path) -> dict[str, Any]: + manifest = read_json(run_dir / "train_manifest.json") + queue = read_json(run_dir / "integration_queue.json") + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + for item in items: + if item.get("status") == "ready_for_integration": + item["status"] = "integration_planned" + integration_dir = run_dir / "integration" / str(item.get("task_id")) + write_json( + integration_dir / "integration_receipt.json", + { + "schema_version": "cento.parallel_integration_train.integration_receipt.v1", + "run_id": run_dir.name, + "task_id": item.get("task_id"), + "status": "dry_run_planned", + "apply": False, + "written_at": now_iso(), + }, + ) + elif item.get("status") in {"ready_for_worker", "waiting"}: + item["status"] = "waiting_for_worker_simulation" + queue["written_at"] = now_iso() + write_json(run_dir / "integration_queue.json", queue) + status = "blocked" if any(item.get("status") == "blocked" for item in items) else "integration_planned" + receipt = train_receipt_payload(run_dir, manifest, queue, status=status) + write_json(run_dir / "train_receipt.json", receipt) + write_train_report(run_dir, manifest, queue, receipt) + train_event(run_dir, "train_integration_dry_run", {"status": status}) + return receipt + + +def validate_train_run(run_dir: Path) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + + def add(name: str, passed: bool, detail: str = "") -> None: + checks.append({"name": name, "status": "passed" if passed else "failed", "detail": detail}) + + manifest = read_json(run_dir / "train_manifest.json") + queue = read_json(run_dir / "integration_queue.json") + check = read_json(run_dir / "workset_check.json") + receipt = read_json(run_dir / "train_receipt.json") + add("manifest.schema", manifest.get("schema_version") == SCHEMA_TRAIN) + add("queue.schema", queue.get("schema_version") == SCHEMA_TRAIN_QUEUE) + add("workset_check.passed", check.get("status") == "passed", str(check.get("stderr") or "")) + add("receipt.schema", receipt.get("schema_version") == SCHEMA_TRAIN_RECEIPT) + add("decision_report.exists", (run_dir / "decision_report.md").exists()) + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + add("queue.non_empty", bool(items), f"{len(items)} items") + add("apply.disabled", manifest.get("integration_policy", {}).get("apply") is False and receipt.get("apply") is False) + if receipt.get("workset_pipeline"): + workset_result = read_json(run_dir / "workset_execute_result.json") + add("workset_execute_result.exists", bool(workset_result)) + add("workset_execute.completed", receipt.get("status") == "workset_completed" and receipt.get("workset_status") == "completed", str(receipt.get("workset_status") or "")) + add("workset_receipt.present", bool(receipt.get("workset_receipt"))) + promotion = read_json(run_dir / "promotion_decision.json") + if promotion: + promotion_manifest = read_json(run_dir / "promotion_manifest.json") + factory_handoff = read_json(run_dir / "factory_handoff.json") + add("promotion.schema", promotion.get("schema_version") == "cento.parallel_integration_train.promotion_decision.v1") + add("promotion_manifest.schema", promotion_manifest.get("schema_version") == "cento.parallel_integration_train.promotion_manifest.v1") + add("factory_handoff.schema", factory_handoff.get("schema_version") == "cento.parallel_integration_train.factory_handoff.v1") + add("promotion_report.exists", (run_dir / "promotion_decision.md").exists()) + status = "passed" if all(item["status"] == "passed" for item in checks) else "failed" + payload = {"schema_version": "cento.parallel_integration_train.validation.v1", "run_id": run_dir.name, "written_at": now_iso(), "status": status, "checks": checks} + write_json(run_dir / "validation_summary.json", payload) + return payload + + +def patch_swarm_latest_run_dir() -> Path | None: + if not PATCH_SWARM_RUNS_ROOT.exists(): + return None + candidates = [path for path in PATCH_SWARM_RUNS_ROOT.iterdir() if path.is_dir() and (path / "patch_swarm_manifest.json").exists()] + return max(candidates, key=lambda path: path.stat().st_mtime) if candidates else None + + +def resolve_patch_swarm_run_dir(value: str | None, *, create: bool = False) -> Path: + if value: + path = Path(value) + if not path.is_absolute() and ("/" not in value and "\\" not in value): + path = PATCH_SWARM_RUNS_ROOT / value + elif not path.is_absolute(): + path = ROOT / path + else: + path = PATCH_SWARM_RUNS_ROOT / f"patch-swarm-{now_stamp()}" if create else patch_swarm_latest_run_dir() + if path is None: + path = PATCH_SWARM_RUNS_ROOT / f"patch-swarm-{now_stamp()}" + if create: + path.mkdir(parents=True, exist_ok=True) + return path + + +def patch_swarm_event(run_dir: Path, event: str, payload: dict[str, Any]) -> None: + append_jsonl(run_dir / "events.ndjson", {"written_at": now_iso(), "event": event, **payload}) + + +def patch_swarm_provider_list(value: str | list[str] | None = None) -> list[str]: + raw_items: list[str] + if isinstance(value, list): + raw_items = [str(item) for item in value] + else: + raw_items = [item.strip() for item in str(value or "").split(",")] + providers: list[str] = [] + for item in raw_items: + if not item: + continue + normalized = PATCH_SWARM_PROVIDER_ALIASES.get(item.strip().lower().replace(" ", "-")) + if normalized and normalized not in providers: + providers.append(normalized) + return providers or list(PATCH_SWARM_PROVIDERS) + + +def patch_swarm_api_candidate_count(candidate_target: int, providers: list[str]) -> int: + providers = providers or list(PATCH_SWARM_PROVIDERS) + distribution = patch_swarm_candidate_distribution(candidate_target, len(PATCH_SWARM_PROREQ_EXECUTIONS)) + total = 0 + global_index = 0 + for candidate_count in distribution: + for _ in range(candidate_count): + global_index += 1 + provider = providers[(global_index - 1) % len(providers)] + if provider == "api-openai": + total += 1 + return total + + +def patch_swarm_estimated_cost(candidate_target: int, providers: list[str], api_sandbox_candidates: int | None = None) -> float: + api_candidate_count = patch_swarm_api_candidate_count(candidate_target, providers) + if api_sandbox_candidates is not None: + api_candidate_count = min(api_candidate_count, max(0, int(api_sandbox_candidates))) + total = api_candidate_count * PATCH_SWARM_API_COST_ESTIMATE_USD + return round(total, 6) + + +def patch_swarm_budget_gate( + run_dir: Path, + *, + budget_cap_usd: float | None, + max_budget_usd: float | None = None, + api_sandbox_candidates: int | None = None, +) -> dict[str, Any]: + manifest = read_json(run_dir / "patch_swarm_manifest.json") + providers = patch_swarm_provider_list(manifest.get("providers") if isinstance(manifest.get("providers"), list) else "") + candidate_target = int(manifest.get("candidate_target") or 0) + api_candidate_count = patch_swarm_api_candidate_count(candidate_target, providers) + metered_api_candidates = min(api_candidate_count, max(0, int(api_sandbox_candidates))) if api_sandbox_candidates is not None else api_candidate_count + estimated = patch_swarm_estimated_cost(candidate_target, providers, api_sandbox_candidates) + cap = float(budget_cap_usd or 0.0) + hard_cap = float(max_budget_usd if max_budget_usd is not None else PATCH_SWARM_DEFAULT_LIVE_HARD_CAP_USD) + blockers: list[str] = [] + if cap <= 0: + blockers.append("live execution requires --budget-cap-usd") + if hard_cap <= 0: + blockers.append("hard budget cap must be positive") + if cap > hard_cap: + blockers.append("budget cap exceeds hard budget cap") + if hard_cap > PATCH_SWARM_DEFAULT_LIVE_HARD_CAP_USD: + blockers.append(f"hard budget cap exceeds ${PATCH_SWARM_DEFAULT_LIVE_HARD_CAP_USD:.2f} rollout ceiling") + if estimated > cap: + blockers.append("estimated provider spend exceeds budget cap") + if "api-openai" in providers and metered_api_candidates > 0 and not os.environ.get("OPENAI_API_KEY"): + blockers.append("OPENAI_API_KEY is missing") + gate = { + "schema_version": "cento.patch_swarm.live_budget_gate.v1", + "run_id": run_dir.name, + "status": "passed" if not blockers else "blocked", + "budget_cap_usd": cap, + "hard_budget_cap_usd": hard_cap, + "estimated_cost_usd": estimated, + "providers": providers, + "candidate_target": candidate_target, + "api_candidate_count": api_candidate_count, + "metered_api_candidate_limit": metered_api_candidates, + "blockers": blockers, + "written_at": now_iso(), + } + write_json(run_dir / "usage_guard.json", gate) + return gate + + +def patch_swarm_candidate_errors(candidate: dict[str, Any], run_dir: Path | None = None) -> list[str]: + errors: list[str] = [] + if candidate.get("schema_version") != SCHEMA_PATCH_SWARM_CANDIDATE: + errors.append("schema_version must be candidate_patch.v1") + for field in ("id", "run_id", "execution_id", "provider", "status", "touched_paths", "patch"): + if field not in candidate: + errors.append(f"missing field: {field}") + provider = str(candidate.get("provider") or "") + if provider not in PATCH_SWARM_PROVIDERS: + errors.append(f"unknown provider: {provider}") + touched_paths = candidate.get("touched_paths") + if not isinstance(touched_paths, list) or not all(isinstance(item, str) and item for item in touched_paths): + errors.append("touched_paths must be a non-empty list of strings") + patch = candidate.get("patch") if isinstance(candidate.get("patch"), dict) else {} + patch_file = str(patch.get("patch_file") or "") + if not patch_file: + errors.append("patch.patch_file is required") + elif run_dir is not None: + resolved = resolve_cento_path(patch_file) + if not resolved.exists(): + errors.append("patch.patch_file does not exist") + expected = str(patch.get("sha256") or "") + actual = hashlib.sha256(resolved.read_bytes()).hexdigest() if resolved.exists() else "" + if expected and actual and expected != actual: + errors.append("patch.sha256 mismatch") + if float(candidate.get("cost_usd_estimate") or 0.0) < 0: + errors.append("cost_usd_estimate must be non-negative") + if str(candidate.get("status") or "") not in {"validated", "rejected", "proposed", "blocked"}: + errors.append("status must be validated, rejected, proposed, or blocked") + return errors + + +def patch_swarm_selected_repo_root(run_dir: Path) -> Path: + metadata = read_json(run_dir / "product_metadata.json") + manifest = read_json(run_dir / "patch_swarm_manifest.json") + selected_repo = metadata.get("selected_repo") if isinstance(metadata.get("selected_repo"), dict) else {} + if not selected_repo: + selected_repo = manifest.get("selected_repo") if isinstance(manifest.get("selected_repo"), dict) else {} + raw_path = str(selected_repo.get("path") or selected_repo.get("root") or "").strip() + if not raw_path: + return ROOT + repo_root = Path(raw_path).expanduser() + if not repo_root.is_absolute(): + repo_root = ROOT / repo_root + return repo_root if repo_root.exists() and repo_root.is_dir() else ROOT + + +def append_patch_swarm_usage(run_dir: Path, candidate: dict[str, Any]) -> None: + provider = str(candidate.get("provider") or "unknown") + row = { + "written_at": now_iso(), + "run_id": run_dir.name, + "candidate_id": str(candidate.get("id") or ""), + "execution_id": str(candidate.get("execution_id") or ""), + "provider": provider, + "cost_usd_estimate": float(candidate.get("cost_usd_estimate") or 0.0), + "duration_ms_estimate": float(candidate.get("duration_ms_estimate") or 0.0), + } + append_jsonl(run_dir / "provider_usage.jsonl", row) + append_jsonl(run_dir / "candidate_spend_ledger.jsonl", row) + + +def patch_swarm_slug(value: str) -> str: + return "".join(ch.lower() if ch.isalnum() else "-" for ch in str(value)).strip("-") or "patch-swarm" + + +def patch_swarm_candidate_distribution(candidate_target: int, execution_count: int) -> list[int]: + candidate_target = max(1, int(candidate_target)) + execution_count = max(1, int(execution_count)) + base = candidate_target // execution_count + remainder = candidate_target % execution_count + return [base + (1 if index < remainder else 0) for index in range(execution_count)] + + +def patch_swarm_prompt_text(objective: str, execution: dict[str, Any], providers: list[str], candidate_count: int) -> str: + provider_lines = [ + f"- {provider}: {PATCH_SWARM_PROVIDER_RUNTIMES.get(provider, {}).get('runtime_profile') or PATCH_SWARM_PROVIDER_RUNTIMES.get(provider, {}).get('output_schema')}" + for provider in providers + ] + return "\n".join( + [ + f"# Patch Swarm ProReq Execution: {execution['title']}", + "", + f"Objective: {objective}", + "", + f"Focus: {execution['focus']}", + "", + f"Generate {candidate_count} candidate patch proposal(s).", + "", + "Allowed providers:", + *provider_lines, + "", + "Rules:", + "- Emit candidate_patch.v1 receipts.", + "- Do not mutate the operator worktree.", + "- Command runtimes must use isolated worktrees.", + "- API workers must return structured patch_proposal.v1 artifacts.", + "- The dedicated integrator is the only execution allowed to select winners.", + "", + ] + ) + + +def patch_swarm_cost_policy(candidate_target: int, max_parallel_agents: int, providers: list[str], live: bool) -> dict[str, Any]: + estimated_cost_usd = patch_swarm_estimated_cost(candidate_target, providers) + return { + "schema_version": "cento.patch_swarm.cost_policy.v1", + "candidate_target": candidate_target, + "max_parallel_agents": max_parallel_agents, + "providers": providers, + "estimated_cost_usd": estimated_cost_usd, + "default_live_hard_cap_usd": PATCH_SWARM_DEFAULT_LIVE_HARD_CAP_USD, + "live_dispatch_enabled": bool(live), + "default_mode": "fixture" if not live else "live", + "hard_cap_required_for_live_api": True, + "deterministic_first": True, + "stop_conditions": [ + "hard budget cap reached", + "duplicate saturation above threshold", + "validator failure rate above threshold", + "no new winning candidate after ranking pass", + ], + } + + +def patch_swarm_execution_manifest( + run_dir: Path, + *, + objective: str, + candidate_target: int, + max_parallel_agents: int, + providers: list[str], + live: bool, +) -> dict[str, Any]: + distribution = patch_swarm_candidate_distribution(candidate_target, len(PATCH_SWARM_PROREQ_EXECUTIONS)) + executions: list[dict[str, Any]] = [] + for index, (execution, candidate_count) in enumerate(zip(PATCH_SWARM_PROREQ_EXECUTIONS, distribution), start=1): + execution_dir = run_dir / "proreq_executions" / execution["id"] + prompt_rel = rel(execution_dir / "prompt.md") + request_rel = rel(execution_dir / "proreq_request.json") + executions.append( + { + "id": execution["id"], + "title": execution["title"], + "sequence": index, + "status": "planned", + "focus": execution["focus"], + "owned_paths": list(execution.get("owned_paths") or []), + "provider_strategy": "round-robin", + "providers": providers, + "candidate_target": candidate_count, + "prompt": prompt_rel, + "request": request_rel, + "output_dir": rel(execution_dir / "candidates"), + } + ) + return { + "schema_version": SCHEMA_PATCH_SWARM_PROREQ, + "run_id": run_dir.name, + "written_at": now_iso(), + "status": "planned", + "objective": objective, + "execution_count": len(executions), + "candidate_target": candidate_target, + "max_parallel_agents": max_parallel_agents, + "providers": providers, + "live_dispatch_enabled": bool(live), + "runtime_adapters": {provider: PATCH_SWARM_PROVIDER_RUNTIMES[provider] for provider in providers if provider in PATCH_SWARM_PROVIDER_RUNTIMES}, + "executions": executions, + "integration_execution": { + **PATCH_SWARM_INTEGRATOR, + "status": "queued", + "depends_on": [execution["id"] for execution in executions], + "artifact": rel(run_dir / "integration_execution" / "integration_execution.json"), + }, + } + + +def patch_swarm_write_report(run_dir: Path, manifest: dict[str, Any], receipt: dict[str, Any] | None = None, integration: dict[str, Any] | None = None, validation: dict[str, Any] | None = None) -> None: + proreq = read_json(run_dir / "proreq_execution_manifest.json") + executions = proreq.get("executions") if isinstance(proreq.get("executions"), list) else [] + lines = [ + "# Patch Swarm Decision Report", + "", + f"- Run: `{run_dir.name}`", + f"- Status: `{(validation or {}).get('status') or (integration or {}).get('status') or (receipt or {}).get('status') or manifest.get('status')}`", + f"- Candidate target: `{manifest.get('candidate_target')}`", + f"- ProReq executions: `{len(executions)}`", + f"- Dedicated integrator: `{PATCH_SWARM_INTEGRATOR['id']}`", + f"- Providers: `{', '.join(manifest.get('providers') or [])}`", + f"- Autopilot ready: `{manifest.get('autopilot', {}).get('mode')}`", + "", + "## Execution Split", + "", + ] + for item in executions: + lines.append(f"- `{item.get('id')}` candidates={item.get('candidate_target')} status=`{item.get('status')}`") + if receipt: + lines.extend( + [ + "", + "## Candidate Summary", + "", + f"- Candidates generated: `{receipt.get('candidate_count', 0)}`", + f"- Passed validation: `{receipt.get('passed_count', 0)}`", + f"- Rejected: `{receipt.get('rejected_count', 0)}`", + f"- Estimated cost: `${float(receipt.get('estimated_cost_usd') or 0.0):.6f}`", + ] + ) + if integration: + lines.extend( + [ + "", + "## Integration", + "", + f"- Selected winners: `{integration.get('selected_count', 0)}`", + f"- Apply: `{integration.get('apply')}`", + f"- Handoff: `{integration.get('safe_integrator_handoff', '-')}`", + ] + ) + lines.extend( + [ + "", + "## Artifacts", + "", + f"- Manifest: `{rel(run_dir / 'patch_swarm_manifest.json')}`", + f"- ProReq execution manifest: `{rel(run_dir / 'proreq_execution_manifest.json')}`", + f"- Candidate index: `{rel(run_dir / 'candidate_index.json')}`", + f"- Ranking: `{rel(run_dir / 'ranking.json')}`", + f"- UI state: `{rel(run_dir / 'ui_state.json')}`", + f"- Events: `{rel(run_dir / 'events.ndjson')}`", + ] + ) + (run_dir / "decision_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def patch_swarm_write_ui_state(run_dir: Path) -> dict[str, Any]: + manifest = read_json(run_dir / "patch_swarm_manifest.json") + proreq = read_json(run_dir / "proreq_execution_manifest.json") + receipt = read_json(run_dir / "patch_swarm_receipt.json") + integration = read_json(run_dir / "integration_execution" / "integration_execution.json") + validation = read_json(run_dir / "validation_summary.json") + ranking = read_json(run_dir / "ranking.json") + product_metadata = read_json(run_dir / "product_metadata.json") + approval = read_json(run_dir / "supervised_approval.json") + decisions = read_json(run_dir / "candidate_decisions.json") + candidate_count = int(receipt.get("candidate_count") or manifest.get("candidate_target") or 0) + selected_count = int(integration.get("selected_count") or 0) + provider_counts = receipt.get("provider_counts") if isinstance(receipt.get("provider_counts"), dict) else {} + executions = proreq.get("executions") if isinstance(proreq.get("executions"), list) else [] + state = { + "schema_version": "cento.patch_swarm.ui_state.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "status": validation.get("status") or integration.get("status") or receipt.get("status") or manifest.get("status", "unknown"), + "run_dir": rel(run_dir), + "summary": { + "candidate_target": manifest.get("candidate_target", 0), + "candidate_count": candidate_count, + "proreq_execution_count": len(executions), + "selected_count": selected_count, + "estimated_cost_usd": receipt.get("estimated_cost_usd", 0.0), + "providers": manifest.get("providers", []), + "provider_counts": provider_counts, + "max_parallel_agents": manifest.get("max_parallel_agents", 0), + }, + "lanes": [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or ""), + "status": str(item.get("status") or "planned"), + "candidate_target": int(item.get("candidate_target") or 0), + "candidate_count": int(item.get("candidate_count") or 0), + "winner": str(item.get("winner") or ""), + } + for item in executions + ], + "ranking": ranking.get("top_candidates", []) if isinstance(ranking.get("top_candidates"), list) else [], + "artifacts": { + "manifest": rel(run_dir / "patch_swarm_manifest.json"), + "candidate_index": rel(run_dir / "candidate_index.json"), + "ranking": rel(run_dir / "ranking.json"), + "integration_execution": rel(run_dir / "integration_execution" / "integration_execution.json") if integration else "", + "validation_summary": rel(run_dir / "validation_summary.json") if validation else "", + "decision_report": rel(run_dir / "decision_report.md"), + }, + "product": product_metadata if product_metadata else {}, + "approval": approval if approval else {}, + "candidate_decisions": decisions if decisions else {}, + } + write_json(run_dir / "ui_state.json", state) + latest_root = PIPELINE_ROOT / "execution" / "patch-swarm" + latest_root.mkdir(parents=True, exist_ok=True) + write_json(latest_root / f"{run_dir.name}_ui_state.json", state) + write_json(latest_root / "latest_ui_state.json", state) + return state + + +def build_patch_swarm_plan( + run_dir: Path, + *, + objective: str = PATCH_SWARM_OBJECTIVE, + candidate_target: int = 100, + max_parallel_agents: int = 5, + providers: list[str] | None = None, + live: bool = False, +) -> dict[str, Any]: + run_dir.mkdir(parents=True, exist_ok=True) + providers = patch_swarm_provider_list(providers) + candidate_target = max(1, int(candidate_target)) + max_parallel_agents = max(1, int(max_parallel_agents)) + proreq = patch_swarm_execution_manifest( + run_dir, + objective=objective or PATCH_SWARM_OBJECTIVE, + candidate_target=candidate_target, + max_parallel_agents=max_parallel_agents, + providers=providers, + live=live, + ) + for execution in proreq["executions"]: + execution_dir = run_dir / "proreq_executions" / str(execution["id"]) + execution_dir.mkdir(parents=True, exist_ok=True) + request = { + "schema_version": "cento.patch_swarm.proreq_execution_request.v1", + "run_id": run_dir.name, + "execution_id": execution["id"], + "title": execution["title"], + "objective": objective or PATCH_SWARM_OBJECTIVE, + "focus": execution["focus"], + "candidate_target": execution["candidate_target"], + "providers": providers, + "runtime_adapters": {provider: PATCH_SWARM_PROVIDER_RUNTIMES[provider] for provider in providers if provider in PATCH_SWARM_PROVIDER_RUNTIMES}, + "owned_paths": execution["owned_paths"], + "output_schema": SCHEMA_PATCH_SWARM_CANDIDATE, + "mutation_policy": "proposal-only; no direct operator worktree mutation", + } + write_json(execution_dir / "proreq_request.json", request) + (execution_dir / "prompt.md").write_text( + patch_swarm_prompt_text(objective or PATCH_SWARM_OBJECTIVE, execution, providers, int(execution["candidate_target"])), + encoding="utf-8", + ) + cost_policy = patch_swarm_cost_policy(candidate_target, max_parallel_agents, providers, live) + manifest = { + "schema_version": SCHEMA_PATCH_SWARM, + "run_id": run_dir.name, + "created_at": now_iso(), + "status": "planned", + "mode": "live" if live else "fixture", + "objective": objective or PATCH_SWARM_OBJECTIVE, + "candidate_target": candidate_target, + "min_candidate_target": 1, + "max_parallel_agents": max_parallel_agents, + "providers": providers, + "live_dispatch_enabled": bool(live), + "proreq_execution_count": len(proreq["executions"]), + "integration_execution": PATCH_SWARM_INTEGRATOR, + "autopilot": { + "mode": "dry-run-compatible", + "entrypoint": f"cento parallel-delivery patch-swarm e2e --run-id {run_dir.name} --candidate-target {candidate_target} --max-parallel-agents {max_parallel_agents} --fixture --json", + "walk_autopilot_status": f"cento walk-autopilot patch-swarm status --run-id {run_dir.name} --json", + }, + "artifacts": { + "proreq_execution_manifest": rel(run_dir / "proreq_execution_manifest.json"), + "candidate_index": rel(run_dir / "candidate_index.json"), + "ranking": rel(run_dir / "ranking.json"), + "cost_policy": rel(run_dir / "cost_policy.json"), + "receipt": rel(run_dir / "patch_swarm_receipt.json"), + "ui_state": rel(run_dir / "ui_state.json"), + "validation": rel(run_dir / "validation_summary.json"), + "decision_report": rel(run_dir / "decision_report.md"), + }, + } + write_json(run_dir / "patch_swarm_manifest.json", manifest) + write_json(run_dir / "proreq_execution_manifest.json", proreq) + write_json(run_dir / "cost_policy.json", cost_policy) + write_json( + run_dir / "autopilot_handoff.json", + { + "schema_version": "cento.patch_swarm.autopilot_handoff.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "status": "planned", + "commands": [ + f"cento parallel-delivery patch-swarm execute {run_dir.name} --fixture --json", + f"cento parallel-delivery patch-swarm integrate {run_dir.name} --dry-run --json", + f"cento parallel-delivery patch-swarm validate {run_dir.name} --json", + ], + "budget_policy": rel(run_dir / "cost_policy.json"), + "ui_state": rel(run_dir / "ui_state.json"), + }, + ) + write_json(run_dir / "candidate_index.json", {"schema_version": "cento.patch_swarm.candidate_index.v1", "run_id": run_dir.name, "candidates": []}) + write_json(run_dir / "ranking.json", {"schema_version": "cento.patch_swarm.ranking.v1", "run_id": run_dir.name, "top_candidates": []}) + patch_swarm_write_report(run_dir, manifest) + patch_swarm_write_ui_state(run_dir) + patch_swarm_event(run_dir, "patch_swarm_planned", {"candidate_target": candidate_target, "providers": providers}) + return manifest + + +def retarget_patch_swarm_to_sandbox(run_dir: Path, sandbox_root: Path) -> None: + manifest = read_json(run_dir / "patch_swarm_manifest.json") + proreq = read_json(run_dir / "proreq_execution_manifest.json") + executions = [item for item in proreq.get("executions", []) if isinstance(item, dict)] + providers = patch_swarm_provider_list(manifest.get("providers") if isinstance(manifest.get("providers"), list) else "") + objective = str(manifest.get("objective") or PATCH_SWARM_OBJECTIVE) + for execution in executions: + execution_id = str(execution.get("id") or "execution") + safe_path = rel(sandbox_root / f"{execution_id}.md") + execution["owned_paths"] = [safe_path] + execution_dir = run_dir / "proreq_executions" / execution_id + request_path = execution_dir / "proreq_request.json" + request = read_json(request_path) + if request: + request["owned_paths"] = [safe_path] + request["sandboxed_by"] = "self-improve-e2e" + write_json(request_path, request) + (execution_dir / "prompt.md").write_text( + patch_swarm_prompt_text(objective, execution, providers, int(execution.get("candidate_target") or 1)), + encoding="utf-8", + ) + proreq["executions"] = executions + proreq["sandbox_root"] = rel(sandbox_root) + write_json(run_dir / "proreq_execution_manifest.json", proreq) + + +def patch_swarm_candidate_patch_text(path: str, candidate_id: str, execution_id: str, provider: str, *, repo_root: Path | None = None) -> str: + base = repo_root or ROOT + resolved = Path(path) if Path(path).is_absolute() else base / path + note = f"Patch Swarm fixture candidate {candidate_id} from {provider} for {execution_id}." + suffix = Path(path).suffix.lower() + if suffix == ".json": + addition = "" + elif suffix == ".md": + addition = f"" + elif suffix in {".js", ".jsx", ".ts", ".tsx"}: + addition = f"// {note}" + else: + addition = f"# {note}" + if resolved.exists(): + lines = resolved.read_text(encoding="utf-8", errors="ignore").splitlines() + context = lines[: max(1, min(3, len(lines)))] + if not context: + return "\n".join( + [ + f"diff --git a/{path} b/{path}", + f"--- a/{path}", + f"+++ b/{path}", + "@@ -0,0 +1 @@", + f"+{addition}", + "", + ] + ) + old_count = len(context) + new_count = old_count + 1 + hunk_lines = [f"@@ -1,{old_count} +1,{new_count} @@"] + insert_after_first = suffix == ".json" or context[0].startswith("#!") + for index, line in enumerate(context): + hunk_lines.append(f" {line}") + if insert_after_first and index == 0: + hunk_lines.append(f"+{addition}") + if not insert_after_first: + hunk_lines.insert(1, f"+{addition}") + return "\n".join( + [ + f"diff --git a/{path} b/{path}", + f"--- a/{path}", + f"+++ b/{path}", + *hunk_lines, + "", + ] + ) + return "\n".join( + [ + f"diff --git a/{path} b/{path}", + "new file mode 100644", + "index 0000000..e69de29", + "--- /dev/null", + f"+++ b/{path}", + "@@ -0,0 +1 @@", + f"+{addition}", + "", + ] + ) + + +def patch_swarm_diff_from_content(path: str, content: str) -> str: + repo_path = path.strip().lstrip("/") + resolved = resolve_cento_path(repo_path) + old_lines = resolved.read_text(encoding="utf-8", errors="ignore").splitlines() if resolved.exists() else [] + new_lines = str(content).splitlines() + from_file = f"a/{repo_path}" if resolved.exists() else "/dev/null" + to_file = f"b/{repo_path}" + diff_lines = list(difflib.unified_diff(old_lines, new_lines, fromfile=from_file, tofile=to_file, lineterm="")) + header = [f"diff --git a/{repo_path} b/{repo_path}"] + if not resolved.exists(): + header.extend(["new file mode 100644", "index 0000000..e69de29"]) + if len(diff_lines) <= 2: + return "" + return "\n".join([*header, *diff_lines]) + "\n" + + +def patch_swarm_api_task_request(run_dir: Path, execution: dict[str, Any], candidate_id: str, objective: str) -> dict[str, Any]: + safe_path = f"workspace/runs/parallel-delivery/patch-swarm/{run_dir.name}/api-sandbox/{candidate_id}.md" + return { + "schema_version": "cento.patch_swarm.api_patch_request.v1", + "worker_id": f"api-openai-{candidate_id}", + "task_id": candidate_id, + "execution_id": str(execution.get("id") or ""), + "title": str(execution.get("title") or ""), + "objective": objective, + "focus": str(execution.get("focus") or ""), + "owned_paths": [safe_path], + "write_paths": [safe_path], + "output_schema": "patch_proposal.v1", + "instructions": [ + "Return one small patch_proposal.v1 artifact.", + f"Use exactly this repo-relative path in owned_path_contents: {safe_path}", + "The content should be a short markdown note suitable for a sandbox receipt.", + "Do not request shell commands and do not include secrets.", + ], + "validation": ["git apply --check on the generated unified diff"], + } + + +def patch_swarm_api_artifact_to_candidate( + run_dir: Path, + execution: dict[str, Any], + candidate_id: str, + local_index: int, + artifact_dir: Path, + worker_result: dict[str, Any], + proc: subprocess.CompletedProcess[str], +) -> tuple[dict[str, Any], dict[str, Any]]: + execution_id = str(execution.get("id") or "") + candidate_dir = artifact_dir.parent + validation_dir = artifact_dir.parent.parent / "validation" + validation_dir.mkdir(parents=True, exist_ok=True) + artifact_path = resolve_cento_path(str(worker_result.get("artifact") or rel(artifact_dir / "artifact.json"))) + artifact = read_json(artifact_path) + cost_receipt_path = resolve_cento_path(str(worker_result.get("cost_receipt") or rel(artifact_dir / "cost_receipt.json"))) + cost_receipt = read_json(cost_receipt_path) + content = artifact.get("content") if isinstance(artifact.get("content"), dict) else {} + path_contents = content.get("owned_path_contents") if isinstance(content.get("owned_path_contents"), list) else [] + diffs: list[str] = [] + touched_paths: list[str] = [] + errors: list[str] = [] + for item in path_contents: + if not isinstance(item, dict): + continue + path = str(item.get("path") or "").strip().lstrip("/") + proposed = str(item.get("content") or "") + if not path: + errors.append("artifact owned_path_contents item missing path") + continue + diff_text = patch_swarm_diff_from_content(path, proposed) + if not diff_text: + errors.append(f"artifact proposed no diff for {path}") + continue + touched_paths.append(path) + diffs.append(diff_text.rstrip()) + if not diffs: + fallback_path = f"workspace/runs/parallel-delivery/patch-swarm/{run_dir.name}/api-sandbox/{candidate_id}-fallback.md" + fallback_content = "\n".join( + [ + f"# API Patch Proposal Fallback {candidate_id}", + "", + str(content.get("summary") or "API worker did not return materializable path contents."), + "", + ] + ) + diffs.append(patch_swarm_diff_from_content(fallback_path, fallback_content).rstrip()) + touched_paths.append(fallback_path) + patch_text = "\n".join(diff for diff in diffs if diff.strip()) + "\n" + patch_path = candidate_dir / f"{candidate_id}.diff" + patch_path.write_text(patch_text, encoding="utf-8") + patch_hash = hashlib.sha256(patch_text.encode("utf-8")).hexdigest() + apply_check = subprocess.run(["git", "apply", "--check", str(patch_path)], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + patch_apply_ok = apply_check.returncode == 0 + artifact_completed = artifact.get("status") == "completed" and content.get("schema_version") == "patch_proposal.v1" + if proc.returncode != 0: + errors.append((proc.stderr or proc.stdout)[-1000:] or "api worker command failed") + if not artifact_completed: + errors.extend(str(item) for item in artifact.get("errors") or ["api worker artifact was not completed"]) + if not patch_apply_ok: + errors.append("git apply check failed") + passed = artifact_completed and patch_apply_ok and not errors + cost = float(worker_result.get("cost_usd_estimate") or cost_receipt.get("cost_usd_estimate") or 0.0) + validation_path = validation_dir / f"{candidate_id}.json" + validation = { + "schema_version": "cento.patch_swarm.candidate_validation.v1", + "run_id": run_dir.name, + "candidate_id": candidate_id, + "execution_id": execution_id, + "status": "passed" if passed else "rejected", + "checks": [ + {"name": "api_worker_exit", "status": "passed" if proc.returncode == 0 else "failed", "stderr_tail": proc.stderr[-1000:]}, + {"name": "api_worker_artifact", "status": "passed" if artifact_completed else "failed", "artifact": rel(artifact_path)}, + {"name": "patch_shape", "status": "passed" if patch_text.startswith("diff --git ") else "failed"}, + {"name": "git_apply_check", "status": "passed" if patch_apply_ok else "failed", "stderr_tail": apply_check.stderr[-1000:]}, + ], + "api_worker": { + "artifact": rel(artifact_path), + "cost_receipt": rel(cost_receipt_path), + "worker_receipt": str(worker_result.get("worker_receipt") or ""), + }, + "written_at": now_iso(), + } + write_json(validation_path, validation) + candidate = { + "schema_version": SCHEMA_PATCH_SWARM_CANDIDATE, + "id": candidate_id, + "run_id": run_dir.name, + "execution_id": execution_id, + "task_id": execution_id, + "provider": "api-openai", + "provider_runtime": PATCH_SWARM_PROVIDER_RUNTIMES["api-openai"], + "candidate_index": local_index, + "status": "validated" if passed else "rejected", + "owned_paths": [str(path) for path in execution.get("owned_paths", []) if isinstance(path, str)], + "touched_paths": touched_paths, + "patch": { + "format": "unified_diff", + "patch_file": rel(patch_path), + "sha256": patch_hash, + }, + "cluster_key": hashlib.sha256(f"{execution_id}:{','.join(touched_paths)}:api".encode("utf-8")).hexdigest()[:16], + "score": round(98.5 - cost * 100, 3) if passed else round(40.0 - cost * 100, 3), + "cost_usd_estimate": round(cost, 6), + "duration_ms_estimate": 0.0, + "validation_receipt": rel(validation_path), + "api_worker_artifact": rel(artifact_path), + "api_worker_cost_receipt": rel(cost_receipt_path), + "api_worker_returncode": proc.returncode, + "errors": errors, + "written_at": now_iso(), + } + candidate_path = candidate_dir / f"{candidate_id}.json" + write_json(candidate_path, candidate) + candidate["candidate_receipt"] = rel(candidate_path) + return candidate, validation + + +def run_patch_swarm_api_candidate( + run_dir: Path, + execution: dict[str, Any], + candidate_id: str, + local_index: int, + *, + objective: str, + api_profile: str, + api_config: str, +) -> tuple[dict[str, Any], dict[str, Any]]: + candidate_dir = run_dir / "proreq_executions" / str(execution.get("id") or "") / "candidates" + artifact_dir = candidate_dir / f"{candidate_id}-api-worker" + artifact_dir.mkdir(parents=True, exist_ok=True) + request_path = artifact_dir / "task_request.json" + write_json(request_path, patch_swarm_api_task_request(run_dir, execution, candidate_id, objective)) + command = [ + sys.executable, + str(ROOT / "scripts" / "cento_openai_worker.py"), + "run", + rel(request_path), + "--out-dir", + rel(artifact_dir), + "--profile", + api_profile, + "--config", + api_config, + "--output-schema", + "patch_proposal.v1", + "--reserved-cost-usd", + str(PATCH_SWARM_API_COST_ESTIMATE_USD), + "--json", + ] + proc = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=180) + try: + worker_result = json.loads(proc.stdout) if proc.stdout.strip() else {} + except json.JSONDecodeError: + worker_result = {"status": "failed", "errors": ["api worker stdout was not JSON"], "stdout_tail": proc.stdout[-1000:]} + return patch_swarm_api_artifact_to_candidate(run_dir, execution, candidate_id, local_index, artifact_dir, worker_result, proc) + + +def execute_patch_swarm( + run_dir: Path, + *, + fixture: bool = True, + budget_cap_usd: float | None = None, + max_budget_usd: float | None = None, + api_sandbox_candidates: int = 1, + api_profile: str = PATCH_SWARM_API_PROFILE, + api_config: str = str(ROOT / ".cento" / "api_workers.yaml"), +) -> dict[str, Any]: + manifest = read_json(run_dir / "patch_swarm_manifest.json") + proreq = read_json(run_dir / "proreq_execution_manifest.json") + executions = [item for item in proreq.get("executions", []) if isinstance(item, dict)] + if not manifest or not executions: + receipt = {"schema_version": SCHEMA_PATCH_SWARM_RECEIPT, "run_id": run_dir.name, "status": "blocked", "errors": ["patch swarm plan is missing"]} + write_json(run_dir / "patch_swarm_receipt.json", receipt) + return receipt + if not fixture: + gate = patch_swarm_budget_gate( + run_dir, + budget_cap_usd=budget_cap_usd, + max_budget_usd=max_budget_usd, + api_sandbox_candidates=api_sandbox_candidates, + ) + if not bool(manifest.get("live_dispatch_enabled")): + receipt = {"schema_version": SCHEMA_PATCH_SWARM_RECEIPT, "run_id": run_dir.name, "status": "blocked", "errors": ["live dispatch requires a live-enabled plan"], "budget_gate": rel(run_dir / "usage_guard.json")} + write_json(run_dir / "patch_swarm_receipt.json", receipt) + return receipt + if gate.get("status") != "passed": + receipt = {"schema_version": SCHEMA_PATCH_SWARM_RECEIPT, "run_id": run_dir.name, "status": "blocked", "errors": gate.get("blockers", []), "budget_gate": rel(run_dir / "usage_guard.json")} + write_json(run_dir / "patch_swarm_receipt.json", receipt) + patch_swarm_event(run_dir, "patch_swarm_live_blocked", {"blockers": gate.get("blockers", [])}) + return receipt + elif not (run_dir / "usage_guard.json").exists(): + write_json( + run_dir / "usage_guard.json", + { + "schema_version": "cento.patch_swarm.live_budget_gate.v1", + "run_id": run_dir.name, + "status": "not_required_fixture", + "estimated_cost_usd": 0.0, + "written_at": now_iso(), + }, + ) + providers = patch_swarm_provider_list(manifest.get("providers") if isinstance(manifest.get("providers"), list) else "") + repo_root = patch_swarm_selected_repo_root(run_dir) + candidate_rows: list[dict[str, Any]] = [] + validation_rows: list[dict[str, Any]] = [] + global_index = 0 + api_dispatch_count = 0 + api_dispatch_limit = max(0, int(api_sandbox_candidates or 0)) + for execution in executions: + execution_id = str(execution.get("id") or "") + execution_dir = run_dir / "proreq_executions" / execution_id + candidate_dir = execution_dir / "candidates" + validation_dir = execution_dir / "validation" + candidate_dir.mkdir(parents=True, exist_ok=True) + validation_dir.mkdir(parents=True, exist_ok=True) + owned_paths = [str(path) for path in execution.get("owned_paths", []) if isinstance(path, str)] + touched_path = owned_paths[0] if owned_paths else f"workspace/runs/parallel-delivery/patch-swarm/{run_dir.name}/{execution_id}.md" + local_candidates = [] + for local_index in range(1, int(execution.get("candidate_target") or 0) + 1): + global_index += 1 + provider = providers[(global_index - 1) % len(providers)] + candidate_id = f"{execution_id}-cand-{local_index:03d}" + if not fixture and provider == "api-openai" and api_dispatch_count < api_dispatch_limit: + api_dispatch_count += 1 + candidate, validation = run_patch_swarm_api_candidate( + run_dir, + execution, + candidate_id, + local_index, + objective=str(manifest.get("objective") or PATCH_SWARM_OBJECTIVE), + api_profile=api_profile, + api_config=api_config, + ) + append_patch_swarm_usage(run_dir, candidate) + local_candidates.append(candidate) + candidate_rows.append(candidate) + validation_rows.append(validation) + continue + patch_text = patch_swarm_candidate_patch_text(touched_path, candidate_id, execution_id, provider, repo_root=repo_root) + patch_hash = hashlib.sha256(patch_text.encode("utf-8")).hexdigest() + cluster_key = hashlib.sha256(f"{execution_id}:{touched_path}:{local_index % 4}".encode("utf-8")).hexdigest()[:16] + quality_passed = local_index % 11 != 0 + duplicate_penalty = (local_index % 4) * 0.75 + cost = 0.0 + patch_path = candidate_dir / f"{candidate_id}.diff" + patch_path.write_text(patch_text, encoding="utf-8") + apply_check = subprocess.run(["git", "apply", "--check", str(patch_path)], cwd=repo_root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + patch_apply_ok = apply_check.returncode == 0 + passed = quality_passed and patch_apply_ok + score = round(100.0 - duplicate_penalty - (0 if passed else 50) - cost * 100, 3) + validation_path = validation_dir / f"{candidate_id}.json" + validation = { + "schema_version": "cento.patch_swarm.candidate_validation.v1", + "run_id": run_dir.name, + "candidate_id": candidate_id, + "execution_id": execution_id, + "status": "passed" if passed else "rejected", + "checks": [ + {"name": "schema", "status": "passed"}, + {"name": "owned_path", "status": "passed"}, + {"name": "patch_shape", "status": "passed" if patch_text.startswith("diff --git ") else "failed"}, + {"name": "git_apply_check", "status": "passed" if patch_apply_ok else "failed", "stderr_tail": apply_check.stderr[-1000:]}, + {"name": "fixture_rejection_gate", "status": "passed" if passed else "failed"}, + ], + "written_at": now_iso(), + } + write_json(validation_path, validation) + candidate = { + "schema_version": SCHEMA_PATCH_SWARM_CANDIDATE, + "id": candidate_id, + "run_id": run_dir.name, + "execution_id": execution_id, + "task_id": execution_id, + "provider": provider, + "provider_runtime": PATCH_SWARM_PROVIDER_RUNTIMES.get(provider, {}), + "candidate_index": local_index, + "status": "validated" if passed else "rejected", + "owned_paths": owned_paths, + "touched_paths": [touched_path], + "patch": { + "format": "unified_diff", + "patch_file": rel(patch_path), + "sha256": patch_hash, + }, + "cluster_key": cluster_key, + "score": score, + "cost_usd_estimate": cost, + "duration_ms_estimate": 350 + (local_index % 7) * 40, + "validation_receipt": rel(validation_path), + "errors": [] if passed else (["git apply check failed"] if not patch_apply_ok else ["fixture rejection gate marked this candidate as lower quality"]), + "written_at": now_iso(), + } + candidate_path = candidate_dir / f"{candidate_id}.json" + write_json(candidate_path, candidate) + candidate["candidate_receipt"] = rel(candidate_path) + append_patch_swarm_usage(run_dir, candidate) + local_candidates.append(candidate) + candidate_rows.append(candidate) + validation_rows.append(validation) + execution["status"] = "completed" + execution["candidate_count"] = len(local_candidates) + execution["candidate_receipts"] = [item["candidate_receipt"] for item in local_candidates] + + cluster_counter = Counter(str(item.get("cluster_key") or "") for item in candidate_rows) + clusters = [ + { + "cluster_key": cluster_key, + "candidate_count": count, + "execution_ids": sorted({str(item.get("execution_id") or "") for item in candidate_rows if item.get("cluster_key") == cluster_key}), + "providers": sorted({str(item.get("provider") or "") for item in candidate_rows if item.get("cluster_key") == cluster_key}), + } + for cluster_key, count in sorted(cluster_counter.items()) + ] + passed_candidates = [item for item in candidate_rows if item.get("status") == "validated"] + ranked = sorted(passed_candidates, key=lambda item: (-float(item.get("score") or 0), float(item.get("cost_usd_estimate") or 0), str(item.get("id") or ""))) + provider_counts = Counter(str(item.get("provider") or "unknown") for item in candidate_rows) + receipt = { + "schema_version": SCHEMA_PATCH_SWARM_RECEIPT, + "run_id": run_dir.name, + "written_at": now_iso(), + "status": "candidates_generated", + "mode": manifest.get("mode", "fixture"), + "candidate_count": len(candidate_rows), + "passed_count": len(passed_candidates), + "rejected_count": len(candidate_rows) - len(passed_candidates), + "proreq_execution_count": len(executions), + "provider_counts": dict(sorted(provider_counts.items())), + "estimated_cost_usd": round(sum(float(item.get("cost_usd_estimate") or 0.0) for item in candidate_rows), 6), + "api_sandbox_candidates_requested": api_dispatch_limit if not fixture else 0, + "api_sandbox_candidates_dispatched": api_dispatch_count, + "candidate_index": rel(run_dir / "candidate_index.json"), + "ranking": rel(run_dir / "ranking.json"), + "dedupe_clusters": rel(run_dir / "dedupe_clusters.json"), + "cost_ledger": rel(run_dir / "cost_ledger.json"), + } + candidate_index = { + "schema_version": "cento.patch_swarm.candidate_index.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "candidate_count": len(candidate_rows), + "candidates": candidate_rows, + } + ranking = { + "schema_version": "cento.patch_swarm.ranking.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "ranking_policy": "validation-first, lower-cost tie break, deterministic id tie break", + "top_candidates": ranked[: max(20, len(executions))], + } + cost_ledger = { + "schema_version": "cento.patch_swarm.cost_ledger.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "total_estimated_cost_usd": receipt["estimated_cost_usd"], + "provider_counts": receipt["provider_counts"], + "provider_costs_usd": { + provider: round(sum(float(item.get("cost_usd_estimate") or 0.0) for item in candidate_rows if item.get("provider") == provider), 6) + for provider in sorted(provider_counts) + }, + } + proreq["status"] = "completed" + proreq["written_at"] = now_iso() + write_json(run_dir / "proreq_execution_manifest.json", proreq) + write_json(run_dir / "candidate_index.json", candidate_index) + write_json(run_dir / "ranking.json", ranking) + write_json(run_dir / "dedupe_clusters.json", {"schema_version": "cento.patch_swarm.dedupe_clusters.v1", "run_id": run_dir.name, "clusters": clusters}) + write_json(run_dir / "cost_ledger.json", cost_ledger) + write_json(run_dir / "patch_swarm_receipt.json", receipt) + manifest["status"] = "candidates_generated" + manifest["updated_at"] = now_iso() + write_json(run_dir / "patch_swarm_manifest.json", manifest) + patch_swarm_write_report(run_dir, manifest, receipt) + patch_swarm_write_ui_state(run_dir) + patch_swarm_event(run_dir, "patch_swarm_candidates_generated", {"candidate_count": len(candidate_rows), "estimated_cost_usd": receipt["estimated_cost_usd"]}) + return receipt + + +def patch_swarm_factory_run_dir(run_dir: Path, value: str = "") -> Path: + if value: + path = Path(value) + return path if path.is_absolute() else ROOT / path + return FACTORY_RUNS_ROOT / f"patch-swarm-{run_dir.name}" + + +def patch_swarm_factory_plan_payload(run_dir: Path, factory_run_dir: Path, selected: list[dict[str, Any]]) -> dict[str, Any]: + tasks: list[dict[str, Any]] = [] + for candidate in selected: + task_id = str(candidate.get("execution_id") or candidate.get("task_id") or candidate.get("id")) + touched_paths = text_list(candidate.get("touched_paths")) + tasks.append( + { + "id": task_id, + "title": f"Promote Patch Swarm winner: {candidate.get('id')}", + "lane": "builder", + "node": "linux", + "owned_scope": touched_paths, + "goal": f"Apply selected Patch Swarm candidate `{candidate.get('id')}` through Factory Safe Integrator.", + "expected_outputs": [{"path": path, "description": "Selected Patch Swarm candidate output"} for path in touched_paths], + "validation_commands": [f"python3 -m json.tool {shlex.quote(str(factory_run_dir / 'patches' / task_id / 'validation-result.json'))}"], + "no_model_eligible": True, + "risk": "low", + "dependencies": [], + } + ) + return { + "schema_version": "factory-plan/v1", + "run_id": factory_run_dir.name, + "request": { + "raw": f"Promote Patch Swarm run {run_dir.name} into Factory Safe Integrator.", + "normalized_goal": "Convert selected candidate_patch.v1 receipts into Factory patch bundles and release evidence.", + }, + "package": "patch-swarm-promotion", + "mode": "dispatch_dry_run", + "risk": "medium", + "budget": { + "ai_call_budget": 0, + "estimated_cost_usd": round(sum(float(item.get("cost_usd_estimate") or 0.0) for item in selected), 6), + "strong_model_calls_allowed": 0, + "cheap_worker_calls_allowed": 0, + }, + "shared_paths": [], + "tasks": tasks, + "integration": { + "strategy": "safe_integrator_from_patch_swarm", + "merge_order": [str(item.get("execution_id") or item.get("task_id") or item.get("id")) for item in selected], + "required_docs": [], + }, + "validation": { + "minimum_tier": "tier0", + "requires_screenshots": False, + "requires_api_smoke": False, + "requires_human_review": True, + }, + "evidence": { + "run_dir": rel(factory_run_dir), + "summary": rel(factory_run_dir / "summary.md"), + }, + "created_at": now_iso(), + "source_patch_swarm": { + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "candidate_index": rel(run_dir / "candidate_index.json"), + "safe_integrator_handoff": rel(run_dir / "safe_integrator_handoff.json"), + }, + } + + +def copy_patch_swarm_outputs_to_factory(factory_run_dir: Path, selected: list[dict[str, Any]], source_run_dir: Path) -> dict[str, Any]: + patches: list[dict[str, Any]] = [] + for candidate in selected: + task_id = str(candidate.get("execution_id") or candidate.get("task_id") or candidate.get("id")) + patch_dir = factory_run_dir / "patches" / task_id + task_dir = factory_run_dir / "tasks" / task_id + patch_dir.mkdir(parents=True, exist_ok=True) + task_dir.mkdir(parents=True, exist_ok=True) + patch_value = str((candidate.get("patch") or {}).get("patch_file") or "") + patch_src = resolve_cento_path(patch_value) + if patch_src.exists(): + shutil.copy2(patch_src, patch_dir / "patch.diff") + else: + (patch_dir / "patch.diff").write_text("", encoding="utf-8") + touched_paths = text_list(candidate.get("touched_paths")) + (patch_dir / "changed-files.txt").write_text("\n".join(touched_paths) + ("\n" if touched_paths else ""), encoding="utf-8") + (patch_dir / "diffstat.txt").write_text("\n".join(f" {path} | Patch Swarm selected candidate" for path in touched_paths) + ("\n" if touched_paths else ""), encoding="utf-8") + validation_src = resolve_cento_path(str(candidate.get("validation_receipt") or "")) + validation_payload = read_json(validation_src) if validation_src.exists() else {} + validation_status = "passed" if str(candidate.get("status") or "") == "validated" and not patch_swarm_candidate_errors(candidate, source_run_dir) else "failed" + write_json( + patch_dir / "validation-result.json", + { + "schema_version": "factory-validation-result/v1", + "status": validation_status, + "source_validation_receipt": rel(validation_src) if validation_src.exists() else "", + "candidate_receipt": str(candidate.get("candidate_receipt") or ""), + "ai_calls_used": 0, + "estimated_ai_cost_usd": 0, + "generated_at": now_iso(), + }, + ) + (patch_dir / "handoff.md").write_text( + "\n".join( + [ + f"# Patch Swarm Candidate {candidate.get('id')}", + "", + f"- Source run: `{source_run_dir.name}`", + f"- Provider: `{candidate.get('provider')}`", + f"- Score: `{candidate.get('score')}`", + f"- Candidate receipt: `{candidate.get('candidate_receipt') or ''}`", + ] + ) + + "\n", + encoding="utf-8", + ) + evidence_dir = patch_dir / "evidence" + evidence_dir.mkdir(exist_ok=True) + write_json(evidence_dir / "candidate-receipt.json", candidate) + patch = { + "schema_version": "factory-patch/v1", + "run_id": factory_run_dir.name, + "task_id": task_id, + "issue_id": None, + "base_sha": factory_dispatch.git_sha(), + "worker_run_id": str(candidate.get("id") or ""), + "patch_file": "patch.diff", + "changed_files": touched_paths, + "diffstat_file": "diffstat.txt", + "handoff_file": "handoff.md", + "validation_result": "validation-result.json", + "evidence_paths": ["evidence/candidate-receipt.json"], + "collection_state": "collected" if patch_src.exists() else "missing", + "owned_path_check": "passed", + "git_apply_check": "pending", + "docs_registry_gate": "pending", + "integration_status": "candidate", + } + write_json(patch_dir / "patch.json", patch) + patches.append({"task_id": task_id, "patch_bundle": rel(patch_dir / "patch.json"), "state": patch["collection_state"], "integration_status": "candidate"}) + write_json(task_dir / "patch-swarm-candidate.json", candidate) + summary = { + "schema_version": "factory-patch-collection/v1", + "run_id": factory_run_dir.name, + "patches": patches, + "ai_calls_used": 0, + "estimated_ai_cost_usd": 0, + "generated_at": now_iso(), + } + write_json(factory_run_dir / "patch-collection-summary.json", summary) + return summary + + +def promote_patch_swarm_to_factory( + run_dir: Path, + selected: list[dict[str, Any]], + *, + factory_run: str = "", + apply: bool = False, + validate_each: bool = False, + branch: str = "", + worktree: str = "", + limit: int = 0, +) -> dict[str, Any]: + factory_run_dir = patch_swarm_factory_run_dir(run_dir, factory_run) + factory_run_dir.mkdir(parents=True, exist_ok=True) + write_json(factory_run_dir / "factory-plan.json", patch_swarm_factory_plan_payload(run_dir, factory_run_dir, selected)) + (factory_run_dir / "summary.md").write_text(f"# Patch Swarm Promotion\n\nSource Patch Swarm: `{run_dir.name}`\n", encoding="utf-8") + factory_tool.materialize_run(factory_run_dir) + factory_dispatch.generate_queue(factory_run_dir) + patch_collection = copy_patch_swarm_outputs_to_factory(factory_run_dir, selected, run_dir) + apply_plan = factory_integrator.create_apply_plan(factory_run_dir) + fanout = factory_integrator.validate_fanout(factory_run_dir) + factory_integrator.update_integration_state(factory_run_dir) + result: dict[str, Any] = { + "schema_version": "cento.patch_swarm.factory_promotion.v1", + "run_id": run_dir.name, + "factory_run_dir": rel(factory_run_dir), + "factory_plan": rel(factory_run_dir / "factory-plan.json"), + "patch_collection": rel(factory_run_dir / "patch-collection-summary.json"), + "apply_plan": rel(factory_run_dir / "integration" / "apply-plan.json"), + "validation_fanout": rel(factory_run_dir / "integration" / "validation-fanout.json"), + "candidate_count": len(apply_plan.get("candidates") or []), + "rejected_count": len(apply_plan.get("rejected") or []), + "fanout_status": fanout.get("status"), + "apply": bool(apply), + "status": "ready_for_apply" if not apply and apply_plan.get("candidates") and fanout.get("status") == "passed" else ("validation_fanout_failed" if fanout.get("status") != "passed" else "planned"), + "patch_collection_count": len(patch_collection.get("patches") or []), + "written_at": now_iso(), + } + if apply: + factory_integrator.prepare_branch(factory_run_dir, branch=branch, worktree=worktree or None) + apply_result = factory_integrator.apply_patches(factory_run_dir, worktree=worktree or None, branch=branch, limit=limit, validate_each=validate_each) + integrated_validation = factory_integrator.validate_integrated(factory_run_dir) + release = factory_integrator.render_release_candidate(factory_run_dir) + result.update( + { + "status": "release_candidate_ready" if integrated_validation.get("decision") == "approve" else "apply_blocked", + "apply_result": rel(factory_run_dir / "integration" / "applied-patches.json"), + "applied_count": len(apply_result.get("applied") or []), + "apply_rejected_count": len(apply_result.get("rejected") or []), + "integrated_validation": rel(factory_run_dir / "integration" / "integrated-validation.json"), + "release_candidate": release.get("release_candidate", ""), + } + ) + write_json(run_dir / "factory_promotion.json", result) + return result + + +def integrate_patch_swarm( + run_dir: Path, + *, + apply: bool = False, + factory_run: str = "", + validate_each: bool = False, + branch: str = "", + worktree: str = "", + limit: int = 0, +) -> dict[str, Any]: + manifest = read_json(run_dir / "patch_swarm_manifest.json") + proreq = read_json(run_dir / "proreq_execution_manifest.json") + candidate_index = read_json(run_dir / "candidate_index.json") + candidates = [item for item in candidate_index.get("candidates", []) if isinstance(item, dict)] + executions = [item for item in proreq.get("executions", []) if isinstance(item, dict)] + selected: list[dict[str, Any]] = [] + blockers: list[str] = [] + for execution in executions: + execution_id = str(execution.get("id") or "") + lane_candidates = [ + item + for item in candidates + if str(item.get("execution_id") or "") == execution_id and str(item.get("status") or "") == "validated" and not patch_swarm_candidate_errors(item, run_dir) + ] + lane_candidates.sort(key=lambda item: (-float(item.get("score") or 0), float(item.get("cost_usd_estimate") or 0), str(item.get("id") or ""))) + if lane_candidates: + winner = lane_candidates[0] + selected.append(winner) + execution["winner"] = winner.get("id") + execution["status"] = "winner_selected" + else: + blockers.append(f"no validated candidate for {execution_id}") + execution["status"] = "blocked" + integration_dir = run_dir / "integration_execution" + integration_dir.mkdir(parents=True, exist_ok=True) + receipts: list[str] = [] + for index, candidate in enumerate(selected, start=1): + receipt_path = integration_dir / f"{candidate['execution_id']}_integration_receipt.json" + payload = { + "schema_version": "cento.patch_swarm.integration_receipt.v1", + "run_id": run_dir.name, + "sequence": index, + "execution_id": candidate["execution_id"], + "candidate_id": candidate["id"], + "status": "accepted_for_safe_integrator", + "apply": False, + "patch_file": candidate.get("patch", {}).get("patch_file", ""), + "touched_paths": candidate.get("touched_paths", []), + "provider": candidate.get("provider", ""), + "score": candidate.get("score"), + "written_at": now_iso(), + } + write_json(receipt_path, payload) + receipts.append(rel(receipt_path)) + handoff_path = run_dir / "safe_integrator_handoff.json" + handoff = { + "schema_version": "cento.patch_swarm.safe_integrator_handoff.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "status": "ready" if selected and not blockers else "blocked", + "apply": bool(apply), + "factory_safe_integrator_required": True, + "selected_candidates": [ + { + "candidate_id": item.get("id"), + "execution_id": item.get("execution_id"), + "provider": item.get("provider"), + "patch_file": item.get("patch", {}).get("patch_file", ""), + "touched_paths": item.get("touched_paths", []), + "score": item.get("score"), + } + for item in selected + ], + "integration_receipts": receipts, + "next_gate": "Factory/Safe Integrator apply plan; optional apply stays in isolated integration worktree", + "blockers": blockers, + } + factory_promotion: dict[str, Any] = {} + if selected and not blockers and (apply or factory_run): + factory_promotion = promote_patch_swarm_to_factory( + run_dir, + selected, + factory_run=factory_run, + apply=apply, + validate_each=validate_each, + branch=branch, + worktree=worktree, + limit=limit, + ) + handoff["factory_promotion"] = rel(run_dir / "factory_promotion.json") + handoff["factory_run_dir"] = factory_promotion.get("factory_run_dir", "") + handoff["status"] = "applied" if factory_promotion.get("status") == "release_candidate_ready" else handoff["status"] + write_json(handoff_path, handoff) + integration_status = "completed" if selected and not blockers else "blocked" + if factory_promotion and apply and factory_promotion.get("status") != "release_candidate_ready": + integration_status = "blocked" + blockers.append(str(factory_promotion.get("status") or "factory_promotion_blocked")) + integration = { + "schema_version": SCHEMA_PATCH_SWARM_INTEGRATION, + "run_id": run_dir.name, + "written_at": now_iso(), + "id": PATCH_SWARM_INTEGRATOR["id"], + "title": PATCH_SWARM_INTEGRATOR["title"], + "status": integration_status, + "apply": bool(apply), + "apply_requested": bool(apply), + "selected_count": len(selected), + "expected_selected_count": len(executions), + "selected_candidates": [item.get("id") for item in selected], + "integration_receipts": receipts, + "safe_integrator_handoff": rel(handoff_path), + "factory_promotion": rel(run_dir / "factory_promotion.json") if factory_promotion else "", + "factory_run_dir": factory_promotion.get("factory_run_dir", "") if factory_promotion else "", + "factory_promotion_status": factory_promotion.get("status", "") if factory_promotion else "", + "blockers": blockers, + } + write_json(integration_dir / "integration_execution.json", integration) + proreq["status"] = "integrated" if not blockers else "blocked" + proreq["written_at"] = now_iso() + write_json(run_dir / "proreq_execution_manifest.json", proreq) + manifest["status"] = "integrated" if not blockers else "blocked" + manifest["updated_at"] = now_iso() + write_json(run_dir / "patch_swarm_manifest.json", manifest) + patch_swarm_write_report(run_dir, manifest, read_json(run_dir / "patch_swarm_receipt.json"), integration) + patch_swarm_write_ui_state(run_dir) + patch_swarm_event(run_dir, "patch_swarm_integrated", {"status": integration["status"], "selected_count": len(selected)}) + return integration + + +def validate_patch_swarm_run(run_dir: Path) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + + def add(name: str, passed: bool, detail: str = "") -> None: + checks.append({"name": name, "status": "passed" if passed else "failed", "detail": detail}) + + manifest = read_json(run_dir / "patch_swarm_manifest.json") + proreq = read_json(run_dir / "proreq_execution_manifest.json") + receipt = read_json(run_dir / "patch_swarm_receipt.json") + candidate_index = read_json(run_dir / "candidate_index.json") + integration = read_json(run_dir / "integration_execution" / "integration_execution.json") + ui_state = read_json(run_dir / "ui_state.json") + executions = proreq.get("executions") if isinstance(proreq.get("executions"), list) else [] + candidates = candidate_index.get("candidates") if isinstance(candidate_index.get("candidates"), list) else [] + providers = set(manifest.get("providers") if isinstance(manifest.get("providers"), list) else []) + add("manifest.schema", manifest.get("schema_version") == SCHEMA_PATCH_SWARM) + add("proreq.schema", proreq.get("schema_version") == SCHEMA_PATCH_SWARM_PROREQ) + add("proreq.execution_count", len(executions) >= 10, f"{len(executions)} execution(s)") + add("candidate_index.schema", candidate_index.get("schema_version") == "cento.patch_swarm.candidate_index.v1") + add("candidate_count.target", len(candidates) >= int(manifest.get("candidate_target") or 0), f"{len(candidates)} candidate(s)") + invalid_candidates = [str(item.get("id") or "") for item in candidates if patch_swarm_candidate_errors(item, run_dir)] + add("candidate_receipts.schema", not invalid_candidates, ", ".join(invalid_candidates[:10])) + add("providers.codex", "codex-exec" in providers) + add("providers.claude", "claude-code" in providers) + add("providers.openai", "api-openai" in providers) + add("receipt.schema", receipt.get("schema_version") == SCHEMA_PATCH_SWARM_RECEIPT) + add("receipt.counts_match", int(receipt.get("candidate_count") or 0) == len(candidates)) + add("integration.schema", integration.get("schema_version") == SCHEMA_PATCH_SWARM_INTEGRATION) + add("integration.dedicated", integration.get("id") == PATCH_SWARM_INTEGRATOR["id"]) + add("integration.selected_per_execution", int(integration.get("selected_count") or 0) == len(executions), f"{integration.get('selected_count')} selected") + add("safe_integrator_handoff.exists", (run_dir / "safe_integrator_handoff.json").exists()) + if integration.get("factory_promotion"): + promotion = read_json(run_dir / "factory_promotion.json") + add("factory_promotion.schema", promotion.get("schema_version") == "cento.patch_swarm.factory_promotion.v1") + add("factory_promotion.fanout", promotion.get("fanout_status") in {"passed", "blocked"}, str(promotion.get("fanout_status") or "")) + add("ui_state.schema", ui_state.get("schema_version") == "cento.patch_swarm.ui_state.v1") + add("decision_report.exists", (run_dir / "decision_report.md").exists()) + status = "passed" if all(item["status"] == "passed" for item in checks) else "failed" + validation = { + "schema_version": SCHEMA_PATCH_SWARM_VALIDATION, + "run_id": run_dir.name, + "written_at": now_iso(), + "status": status, + "checks": checks, + } + write_json(run_dir / "validation_summary.json", validation) + manifest["status"] = "validated" if status == "passed" else "validation_failed" + manifest["updated_at"] = now_iso() + write_json(run_dir / "patch_swarm_manifest.json", manifest) + patch_swarm_write_report(run_dir, manifest, receipt, integration, validation) + patch_swarm_write_ui_state(run_dir) + patch_swarm_event(run_dir, "patch_swarm_validated", {"status": status}) + return validation + + +def plan_manifest(run_dir: Path) -> dict[str, Any]: + passes = [] + for index, item in enumerate(WORKSTREAMS, start=1): + passes.append( + { + "id": item["id"], + "title": item["title"], + "sequence": index, + "operator_prompt": pass_prompt(item), + "image_task": image_task(item), + "expected_outputs": [ + "pro_backend_request.json", + "image_generation_request.json", + "story_index.json", + "parallel_patch_workset.json", + "manifest_integration_policy.json", + "integration_plan.json", + "validation_plan.json", + "hard_proreq_evidence.json", + ], + } + ) + return { + "schema_version": SCHEMA_PLAN, + "id": run_dir.name, + "created_at": now_iso(), + "goal": BASE_VISION, + "target": { + "workers": 10, + "integrator_validator_lanes": "2-3", + "latency_target": "2-3 minutes", + "marginal_cost_target_usd": "3-5", + "ai_fallback_policy": "only-if-needed after deterministic gates cannot classify", + }, + "hard_proreq_passes": passes, + "integration": { + "lanes": ["patch-safety", "focused-validation", "release-evidence"], + "mutation_policy": "workers never mutate repo files directly; local integrator/materializer owns mutation", + "release_policy": "produce release packet and receipts; do not auto-merge main", + }, + "validation": { + "required": [ + "each Hard ProReq pass completed", + "each generated workset passes cento workset check", + "demo workset has 10 tasks and max_parallel 10", + "receipt records Pro/image live status and skip/failure reasons", + ] + }, + "demo": { + "workset": rel(run_dir / "demo" / "workset.json"), + "runtime": "fixture", + "max_parallel": 10, + "validation": "smoke", + }, + } + + +def write_plan(run_dir: Path) -> dict[str, Any]: + manifest = plan_manifest(run_dir) + write_json(run_dir / "implementation_manifest.json", manifest) + return manifest + + +def execute_proreq_pass(pass_spec: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: + env_updates = { + "CENTO_HARD_PROREQ_IMAGE_TASK": str(pass_spec["image_task"]), + "CENTO_HARD_PROREQ_STEP_TIMEOUT": str(args.step_timeout), + "CENTO_HARD_PROREQ_PRO_TIMEOUT": str(args.pro_timeout), + "CENTO_HARD_PROREQ_IMAGE_TIMEOUT": str(args.image_timeout), + } + if args.live_pro and os.environ.get("OPENAI_API_KEY"): + env_updates["CENTO_HARD_PROREQ_DISPATCH_PRO"] = "1" + if args.reference_screenshot: + env_updates["CENTO_HARD_PROREQ_REFERENCE_SCREENSHOT"] = args.reference_screenshot + with scoped_env(env_updates): + response = app.dev_pipeline_start_pipeline_run( + pipeline_payload(str(pass_spec["operator_prompt"]), args.reference_screenshot), + spawn=False, + ) + run_id = str(response.get("run_id") or "") + app.dev_pipeline_spawn_execution_e2e(app.DEV_PIPELINE_STUDIO_ROOT, app.HARD_PROREQ_PROJECT_ID, app.HARD_PROREQ_TEMPLATE_ID, run_id) + final_payload = wait_for_pipeline(run_id, args.per_run_timeout, args.poll_seconds) + artifacts = summarize_hard_proreq(run_id) + return { + "id": pass_spec["id"], + "title": pass_spec["title"], + "sequence": pass_spec["sequence"], + "run_id": run_id, + "status": str(final_payload.get("status") or ""), + "duration_seconds": int(final_payload.get("duration_seconds") or 0), + "started_at": str(final_payload.get("started_at") or ""), + "finished_at": str(final_payload.get("finished_at") or ""), + "artifacts": artifacts, + "workset_check": run_workset_check(str(artifacts.get("parallel_patch_workset") or "")), + } + + +def write_demo_workset(run_dir: Path) -> Path: + tasks = [] + for index, path in enumerate(DEMO_TARGET_PATHS, start=1): + tasks.append( + { + "id": f"demo-lane-{index:02d}", + "worker_id": f"fixture-worker-{index:02d}", + "task": f"Patch demo lane {index:02d}", + "description": "Fixture worker proves the parallel delivery worker contract through dry-run integration.", + "write_paths": [path], + "read_paths": ["docs/parallel-ai-delivery-roadmap.md"], + "depends_on": [], + "cost_usd_estimate": 0.0, + } + ) + workset = { + "schema_version": "cento.workset.v1", + "id": f"parallel_delivery_demo_{run_dir.name.lower()}", + "mode": "fast", + "max_parallel": 10, + "execution_model": "parallel", + "integration": "sequential", + "routes": ["/parallel-delivery/demo"], + "read_paths": ["docs/parallel-ai-delivery-roadmap.md"], + "tasks": tasks, + } + path = run_dir / "demo" / "workset.json" + write_json(path, workset) + return path + + +def run_demo(run_dir: Path, *, execute: bool = True) -> dict[str, Any]: + workset_path = write_demo_workset(run_dir) + check = run_workset_check(rel(workset_path)) + receipt: dict[str, Any] = { + "schema_version": "cento.parallel_delivery.demo_receipt.v1", + "written_at": now_iso(), + "status": "planned", + "workset": rel(workset_path), + "workset_check": check, + } + if execute and check.get("status") == "passed": + command = [ + "python3", + "scripts/cento_workset.py", + "execute", + rel(workset_path), + "--max-parallel", + "10", + "--runtime", + "fixture", + "--integrate", + "sequential", + "--validation", + "smoke", + "--allow-dirty-owned", + "--json", + ] + result = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + payload: dict[str, Any] = {} + try: + payload = json.loads(result.stdout or "{}") + except json.JSONDecodeError: + payload = {} + workset_receipt = payload.get("workset_receipt") + receipt.update( + { + "status": "completed" if result.returncode == 0 and payload.get("status") == "completed" else "failed", + "command": command, + "exit_code": result.returncode, + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + "workset_receipt": workset_receipt, + "workset_result": payload, + } + ) + write_json(run_dir / "demo" / "demo_receipt.json", receipt) + return receipt + + +def compose_execution_manifest(run_dir: Path, receipt: dict[str, Any], demo_receipt: dict[str, Any] | None) -> dict[str, Any]: + passes = receipt.get("passes") if isinstance(receipt.get("passes"), list) else [] + manifest = { + "schema_version": "cento.parallel_delivery.execution_manifest.v1", + "run_id": run_dir.name, + "written_at": now_iso(), + "source_plan": rel(run_dir / "implementation_manifest.json"), + "proreq_receipt": rel(run_dir / "proreq_receipt.json"), + "demo_receipt": rel(run_dir / "demo" / "demo_receipt.json") if demo_receipt else "", + "workstreams": [ + { + "id": item.get("id"), + "title": item.get("title"), + "hard_proreq_run_id": item.get("run_id"), + "story_count": item.get("artifacts", {}).get("story_count") if isinstance(item.get("artifacts"), dict) else 0, + "parallel_patch_workset": item.get("artifacts", {}).get("parallel_patch_workset") if isinstance(item.get("artifacts"), dict) else "", + "workset_check": item.get("workset_check", {}).get("status") if isinstance(item.get("workset_check"), dict) else "", + } + for item in passes + ], + "integrator_validator_lanes": ["patch-safety", "focused-validation", "release-evidence"], + "fallback_policy": { + "mode": "only-if-needed", + "trigger": "deterministic gates cannot classify conflict, missing evidence, failed validation, or ambiguity", + "reviewer_profile": "api-mini-integrator", + }, + "demo": { + "status": (demo_receipt or {}).get("status", ""), + "workset": (demo_receipt or {}).get("workset", ""), + "workset_receipt": (demo_receipt or {}).get("workset_receipt", ""), + }, + } + write_json(run_dir / "execution_manifest.json", manifest) + return manifest + + +def validate_run(run_dir: Path) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + + def add(name: str, status: str, detail: str = "") -> None: + checks.append({"name": name, "status": status, "detail": detail}) + + plan = read_json(run_dir / "implementation_manifest.json") + receipt = read_json(run_dir / "proreq_receipt.json") + execution = read_json(run_dir / "execution_manifest.json") + demo = read_json(run_dir / "demo" / "demo_receipt.json") + add("plan.schema", "passed" if plan.get("schema_version") == SCHEMA_PLAN else "failed") + add("receipt.schema", "passed" if receipt.get("schema_version") == SCHEMA_RECEIPT else "failed") + add("execution.schema", "passed" if execution.get("schema_version") == "cento.parallel_delivery.execution_manifest.v1" else "failed") + passes = receipt.get("passes") if isinstance(receipt.get("passes"), list) else [] + expected_pass_count = int(receipt.get("expected_pass_count") or len(WORKSTREAMS)) + add("proreq.pass_count", "passed" if len(passes) == expected_pass_count else "failed", f"{len(passes)}/{expected_pass_count}") + completed = [item for item in passes if item.get("status") == "completed"] + add("proreq.completed", "passed" if len(completed) == len(passes) and passes else "failed", f"{len(completed)}/{len(passes)}") + workset_passed = 0 + for item in passes: + if isinstance(item.get("workset_check"), dict) and item["workset_check"].get("status") == "passed": + workset_passed += 1 + add("proreq.workset_checks", "passed" if workset_passed == len(passes) and passes else "failed", f"{workset_passed}/{len(passes)}") + demo_required = bool(receipt.get("demo_required", True)) + if demo_required: + add("demo.receipt", "passed" if demo.get("schema_version") == "cento.parallel_delivery.demo_receipt.v1" else "failed") + if demo: + add("demo.status", "passed" if demo.get("status") in {"completed", "planned"} else "failed", str(demo.get("status") or "")) + else: + add("demo.skipped", "passed") + status = "passed" if all(check["status"] == "passed" for check in checks) else "failed" + payload = {"schema_version": SCHEMA_VALIDATION, "written_at": now_iso(), "status": status, "checks": checks} + write_json(run_dir / "validation_summary.json", payload) + return payload + + +def validate_selected_run(run_dir: Path) -> dict[str, Any]: + fixture_run_dir = selected_patch_swarm_fixture_e2e_run_dir(run_dir) + if fixture_run_dir: + validation = validation_e2e_tool.validate_e2e_run(fixture_run_dir) + status = "passed" if validation.get("ok") else "failed" + return { + "schema_version": SCHEMA_VALIDATION, + "written_at": now_iso(), + "status": status, + "run_kind": "patch_swarm_fixture_e2e", + "validated_run_dir": rel(fixture_run_dir), + "checks": [ + { + "name": "patch_swarm_fixture_e2e", + "status": status, + "detail": "; ".join(str(item) for item in validation.get("errors") or []), + } + ], + "patch_swarm_e2e": validation, + } + payload = validate_run(run_dir) + payload.setdefault("run_kind", "parallel_delivery") + return payload + + +def status_for_selected_run(run_dir: Path) -> dict[str, Any]: + if (run_dir / "worker-status.json").exists(): + payload = worker_status_tool.status_for_run(run_dir) + payload.update( + { + "schema_version": "cento.parallel_delivery.status.v1", + "run_kind": "patch_swarm_worker_status", + "status": "dry_run_dispatch_planned" if payload.get("ok") else "blocked", + "validation": "worker_status_ready" if payload.get("ok") else "worker_status_failed", + "demo": "not_applicable", + "execution_manifest": "", + } + ) + return payload + fixture_run_dir = selected_patch_swarm_fixture_e2e_run_dir(run_dir) + if fixture_run_dir: + summary = read_json(fixture_run_dir / "validation-summary.json") + return { + "schema_version": "cento.parallel_delivery.status.v1", + "run_kind": "patch_swarm_fixture_e2e", + "run_dir": rel(run_dir), + "validated_run_dir": rel(fixture_run_dir), + "status": summary.get("state") or summary.get("overall", "unknown"), + "pass_count": int(summary.get("candidate_count") or 0), + "validation": summary.get("overall", "unknown"), + "demo": "not_applicable", + "execution_manifest": "", + } + receipt = read_json(run_dir / "proreq_receipt.json") + validation = read_json(run_dir / "validation_summary.json") + demo = read_json(run_dir / "demo" / "demo_receipt.json") + return { + "schema_version": "cento.parallel_delivery.status.v1", + "run_kind": "parallel_delivery", + "run_dir": rel(run_dir), + "status": receipt.get("status", "unknown"), + "pass_count": len(receipt.get("passes") or []), + "validation": validation.get("status", "unknown"), + "demo": demo.get("status", "unknown"), + "execution_manifest": rel(run_dir / "execution_manifest.json"), + } + + +def self_latest_dir() -> Path: + return SELF_IMPROVE_RUNS_ROOT / "latest" + + +def self_improve_latest_run_dir() -> Path | None: + if not SELF_IMPROVE_RUNS_ROOT.exists(): + return None + candidates = [ + path + for path in SELF_IMPROVE_RUNS_ROOT.iterdir() + if path.is_dir() and path.name != "latest" and (path / "nightly_cycle_manifest.json").exists() + ] + if not candidates: + return None + return max(candidates, key=lambda path: path.stat().st_mtime) + + +def resolve_self_run_dir(value: str | None, *, create: bool = False) -> Path: + if value: + path = Path(value) + if not path.is_absolute(): + path = ROOT / path + else: + path = SELF_IMPROVE_RUNS_ROOT / now_stamp() if create else self_improve_latest_run_dir() + if path is None: + path = SELF_IMPROVE_RUNS_ROOT / now_stamp() + if create: + path.mkdir(parents=True, exist_ok=True) + return path + + +def write_self_json(run_dir: Path, name: str, payload: Any, *, mirror_latest: bool = True) -> str: + path = run_dir / name + write_json(path, payload) + if mirror_latest: + write_json(self_latest_dir() / name, payload) + return rel(path) + + +def previous_continuous_handoff() -> tuple[dict[str, Any], str]: + if not CONTINUOUS_PROREQ_ROOT.exists(): + return {}, "" + candidates = sorted(CONTINUOUS_PROREQ_ROOT.glob("*/validation_handoff.json"), key=lambda path: path.stat().st_mtime) + if not candidates: + return {}, "" + path = candidates[-1] + return read_json(path), rel(path) + + +def resolve_self_seed() -> dict[str, Any]: + latest_next = self_latest_dir() / "next_cycle_request.json" + latest_payload = read_json(latest_next) + if latest_payload: + return { + "source": "latest_next_cycle_request", + "path": rel(latest_next), + "request": latest_payload, + "source_payload": latest_payload, + } + handoff, handoff_path = previous_continuous_handoff() + if handoff: + request = handoff.get("next_cycle_request") if isinstance(handoff.get("next_cycle_request"), dict) else {} + return { + "source": "previous_continuous_proreq_handoff", + "path": handoff_path, + "request": request or handoff, + "source_payload": handoff, + } + request = { + "objective": "Run a four-pass Cento nightly self-improvement planning cycle.", + "required_first_wave": [ + "Plan scope and guardrails.", + "Plan architecture.", + "Plan integration and workset strategy.", + "Plan validation, promotion, and the next-night request.", + ], + "budget_model_policy": AGENT_PREFERRED_COMPUTE_POLICY, + } + return {"source": "default_seed", "path": "", "request": request, "source_payload": request} + + +def seed_objective(seed: dict[str, Any]) -> str: + request = seed.get("request") if isinstance(seed.get("request"), dict) else {} + objective = str(request.get("objective") or "").strip() + if objective: + return objective + return "Run a four-pass Cento nightly self-improvement planning cycle." + + +def path_from_repo_value(value: str) -> Path: + path = Path(value) + if not path.is_absolute(): + path = ROOT / path + return path + + +def workset_declared_path_policy(workset_path: str) -> dict[str, Any]: + workset = read_json(path_from_repo_value(workset_path)) if workset_path else {} + tasks = workset.get("tasks") if isinstance(workset.get("tasks"), list) else [] + api_worker_declared = any( + isinstance(task, dict) + and ( + str(task.get("api_profile") or "") + or str(task.get("output_schema") or "") + or str(task.get("worker_id") or "").startswith("api-") + ) + for task in tasks + ) + return { + "runtime": "api-openai" if api_worker_declared else "", + "allow_creates": api_worker_declared, + "reason": "api-worker-created file plans may own new paths" if api_worker_declared else "default existing-file edit policy", + } + + +def workset_task_count(workset_path: str) -> int: + workset = read_json(path_from_repo_value(workset_path)) if workset_path else {} + tasks = workset.get("tasks") if isinstance(workset.get("tasks"), list) else [] + return len([item for item in tasks if isinstance(item, dict)]) + + +def pro_state_for_run(root: Path) -> dict[str, Any]: + plan = read_json(root / "pro_backend_plan.json") + response = read_json(root / "pro_backend_response.json") + response_body = response.get("response") if isinstance(response.get("response"), dict) else {} + response_status = str(response.get("status") or response_body.get("status") or "") + plan_valid = plan.get("schema_version") == "cento.hard_proreq_backend_plan.v1" and bool(str(plan.get("summary") or "").strip()) + reason = "" + if not plan_valid: + reason = "Pro plan artifacts were missing or blank when summarized." + elif response_status == "failed": + reason = str(response.get("error") or "") + elif response_status == "skipped": + reason = str(response.get("skip_code") or "") + return { + "status": "completed" if plan_valid else "degraded", + "response_status": response_status, + "dispatch_status": str(response.get("dispatch_status") or response_body.get("status") or ""), + "skip_code": str(response.get("skip_code") or ""), + "model": str(response.get("model") or response_body.get("model") or ""), + "plan_present": bool(plan), + "plan_valid": plan_valid, + "reason": reason, + "plan": rel(root / "pro_backend_plan.json"), + "response": rel(root / "pro_backend_response.json"), + } + + +def image_state_for_run(root: Path) -> dict[str, Any]: + response = read_json(root / "image_generation_response.json") + response_body = response.get("response") if isinstance(response.get("response"), dict) else {} + error_payload = response_body.get("error") if isinstance(response_body.get("error"), dict) else {} + status = str(response.get("status") or "") + http_status = response.get("http_status") + return { + "requested": bool(response), + "status": status or "missing", + "blocking": False, + "model": str(response.get("model") or "gpt-image-2"), + "http_status": http_status, + "reason": str(error_payload.get("message") or response.get("error") or response.get("skip_code") or ""), + "generated_screenshot": bool(response.get("output_image")), + "evidence": rel(root / "image_generation_response.json"), + } + + +def pass_next_guidance(pass_index: int, title: str, record: dict[str, Any], seed: dict[str, Any]) -> str: + if record.get("status") == "degraded": + return ( + f"Use pass {pass_index} only as failure evidence in the next pass. " + f"Repair blockers: {', '.join(record.get('blocking_reasons') or ['unclassified degraded pass'])}." + ) + if pass_index < len(SELF_IMPROVE_PASS_FOCUS): + return f"Use pass {pass_index} {title} guidance to drive pass {pass_index + 1}: {SELF_IMPROVE_PASS_FOCUS[pass_index]['title']}." + return f"Write the next nightly cycle request from pass {pass_index} validation and promotion guidance for: {seed_objective(seed)}" + + +def self_improve_prompt(pass_index: int, focus: dict[str, str], seed: dict[str, Any], prior_records: list[dict[str, Any]]) -> str: + request = seed.get("request") if isinstance(seed.get("request"), dict) else {} + prior = prior_records[-1] if prior_records else {} + prior_status = str(prior.get("status") or "none") + prior_guidance = str(prior.get("next_guidance") or "") + if prior and prior_status == "degraded": + prior_guidance = "FAILURE EVIDENCE ONLY: " + prior_guidance + return ( + f"Nightly Cento self-improvement loop pass {pass_index}/4: {focus['title']}.\n\n" + f"Seed source: {seed.get('source')} {seed.get('path') or ''}\n" + f"Objective:\n{seed_objective(seed)}\n\n" + f"Seed request JSON:\n{json.dumps(request, indent=2, sort_keys=False)[:6000]}\n\n" + f"Focus for this pass:\n{focus['focus']}\n\n" + f"Previous pass status: {prior_status}\n" + f"Previous pass guidance and next-step request:\n{prior_guidance or 'No previous pass; establish scope and guardrails first.'}\n\n" + "Return backend-only planning artifacts for Cento. Include integration manifests, validation manifests, " + "workset path-policy guidance, nonblocking image evidence handling, promotion criteria, spend controls, " + "agent-preferred compute routing, residual risks, and an exact next-step request. Do not propose automatic " + "implementation execution; the loop must plan, gate, recommend, and stop." + ) + + +def execute_self_improve_pass(pass_index: int, focus: dict[str, str], seed: dict[str, Any], prior_records: list[dict[str, Any]], args: argparse.Namespace) -> dict[str, Any]: + prompt = self_improve_prompt(pass_index, focus, seed, prior_records) + env_updates = { + "CENTO_HARD_PROREQ_IMAGE_TASK": ( + "Create a dense Cento operator UI image prompt for the nightly self-improvement loop. " + "Show four sequential ProReq passes, nonblocking image evidence, validation gates, " + "promotion recommendation, and next-cycle request." + ), + "CENTO_HARD_PROREQ_STEP_TIMEOUT": str(args.step_timeout), + "CENTO_HARD_PROREQ_PRO_TIMEOUT": str(args.pro_timeout), + "CENTO_HARD_PROREQ_IMAGE_TIMEOUT": str(args.image_timeout), + } + if args.live_pro and os.environ.get("OPENAI_API_KEY"): + env_updates["CENTO_HARD_PROREQ_DISPATCH_PRO"] = "1" + if args.reference_screenshot: + env_updates["CENTO_HARD_PROREQ_REFERENCE_SCREENSHOT"] = args.reference_screenshot + with scoped_env(env_updates): + response = app.dev_pipeline_start_pipeline_run(pipeline_payload(prompt, args.reference_screenshot), spawn=False) + run_id_value = response.get("run_id") + if not run_id_value and isinstance(response.get("execution_run"), dict): + run_id_value = response["execution_run"].get("run_id") + run_id = str(run_id_value or "") + if not args.plan_only: + app.dev_pipeline_spawn_execution_e2e(app.DEV_PIPELINE_STUDIO_ROOT, app.HARD_PROREQ_PROJECT_ID, app.HARD_PROREQ_TEMPLATE_ID, run_id) + final_payload = wait_for_pipeline(run_id, args.per_run_timeout, args.poll_seconds) + else: + final_payload = pipeline_run_payload(run_id) + root = hard_proreq_root(run_id) + artifacts = summarize_hard_proreq(run_id) + pro_state = pro_state_for_run(root) + image_state = image_state_for_run(root) + path_policy = workset_declared_path_policy(str(artifacts.get("parallel_patch_workset") or "")) + workset_check = run_workset_check( + str(artifacts.get("parallel_patch_workset") or ""), + runtime=str(path_policy.get("runtime") or ""), + allow_creates=bool(path_policy.get("allow_creates")), + ) + workset_tasks = workset_task_count(str(artifacts.get("parallel_patch_workset") or "")) + blocking_reasons: list[str] = [] + final_status = str(final_payload.get("status") or "") + if final_status and final_status != "completed": + blocking_reasons.append(f"child run status is {final_status}") + if pro_state["status"] != "completed": + blocking_reasons.append(str(pro_state.get("reason") or "missing or blank Pro artifacts")) + if workset_check.get("status") != "passed": + blocking_reasons.append("workset check failed under declared path policy") + status = "completed" if not blocking_reasons else "degraded" + record = { + "schema_version": SCHEMA_SELF_PASS, + "cycle_pass": pass_index, + "pass_id": focus["id"], + "title": focus["title"], + "status": status, + "blocking_reasons": blocking_reasons, + "child_run_id": run_id, + "child_run_status": final_status, + "child_run_dir": rel(root), + "pro_state": pro_state, + "image_state": image_state, + "counts": { + "workstreams": int(artifacts.get("story_count") or 0), + "stories": int(artifacts.get("story_count") or 0), + "workset_tasks": workset_tasks, + }, + "artifacts": artifacts, + "workset_path_policy": path_policy, + "workset_check": workset_check, + "next_guidance": "", + "prompt_excerpt": prompt[:3000], + "written_at": now_iso(), + } + record["next_guidance"] = pass_next_guidance(pass_index, focus["title"], record, seed) + return record + + +def self_improve_validation(run_dir: Path) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + + def add(kind: str, name: str, status: str, detail: str = "") -> None: + checks.append({"kind": kind, "name": name, "status": status, "detail": detail}) + + manifest = read_json(run_dir / "nightly_cycle_manifest.json") + add("blocking", "manifest.schema", "passed" if manifest.get("schema_version") == SCHEMA_SELF_MANIFEST else "failed") + pass_records = [read_json(run_dir / f"pass_{index:02d}_child_run_summary.json") for index in range(1, 5)] + add("blocking", "pass.count", "passed" if all(pass_records) else "failed", f"{sum(1 for item in pass_records if item)}/4") + for index, record in enumerate(pass_records, start=1): + add("blocking", f"pass_{index:02d}.status", "passed" if record.get("status") == "completed" else "failed", str(record.get("blocking_reasons") or "")) + pro_state = record.get("pro_state") if isinstance(record.get("pro_state"), dict) else {} + add("blocking", f"pass_{index:02d}.pro_artifacts", "passed" if pro_state.get("plan_valid") else "failed", str(pro_state.get("reason") or "")) + workset_check = record.get("workset_check") if isinstance(record.get("workset_check"), dict) else {} + add("blocking", f"pass_{index:02d}.workset_policy", "passed" if workset_check.get("status") == "passed" else "failed", str(workset_check.get("stderr") or workset_check.get("errors") or "")) + image_state = record.get("image_state") if isinstance(record.get("image_state"), dict) else {} + add("nonblocking", f"pass_{index:02d}.image_lane", "passed" if image_state.get("status") in {"completed", "skipped"} else "evidence", str(image_state.get("reason") or image_state.get("status") or "")) + latest_manifest = read_json(self_latest_dir() / "nightly_cycle_manifest.json") + latest_cycle_id = str(latest_manifest.get("cycle_id") or "") + add( + "blocking", + "latest.mirror_current", + "passed" if latest_cycle_id == manifest.get("cycle_id") and bool(manifest.get("cycle_id")) else "failed", + latest_cycle_id or "missing latest manifest", + ) + latest_drift_errors: list[str] = [] + for index, record in enumerate(pass_records, start=1): + latest_record = read_json(self_latest_dir() / f"pass_{index:02d}_child_run_summary.json") + if latest_record.get("cycle_id") != manifest.get("cycle_id") or latest_record.get("child_run_id") != record.get("child_run_id"): + latest_drift_errors.append(f"pass_{index:02d}") + if read_json(self_latest_dir() / "loop_metrics.json").get("cycle_id") != manifest.get("cycle_id"): + latest_drift_errors.append("loop_metrics") + if read_json(self_latest_dir() / "promotion_recommendation.json").get("cycle_id") != manifest.get("cycle_id"): + latest_drift_errors.append("promotion_recommendation") + if read_json(self_latest_dir() / "evidence_handoff.json").get("cycle_id") != manifest.get("cycle_id"): + latest_drift_errors.append("evidence_handoff") + if read_json(self_latest_dir() / "next_cycle_request.json").get("source_cycle") != manifest.get("cycle_id"): + latest_drift_errors.append("next_cycle_request") + add( + "blocking", + "latest.artifact_drift", + "passed" if not latest_drift_errors else "failed", + ", ".join(latest_drift_errors), + ) + blocking = [item for item in checks if item["kind"] == "blocking"] + nonblocking = [item for item in checks if item["kind"] == "nonblocking"] + status = "passed" if all(item["status"] == "passed" for item in blocking) else "failed" + return { + "schema_version": SCHEMA_SELF_GATES, + "cycle_id": manifest.get("cycle_id", run_dir.name), + "status": status, + "blocking": blocking, + "nonblocking": nonblocking, + "written_at": now_iso(), + } + + +def promotion_recommendation(pass_records: list[dict[str, Any]], gates: dict[str, Any]) -> dict[str, Any]: + if gates.get("status") != "passed": + blocking_names = [str(item.get("name")) for item in gates.get("blocking", []) if isinstance(item, dict) and item.get("status") != "passed"] + value = "repair_pipeline_first" if any("pro_artifacts" in name or "workset_policy" in name or "latest" in name for name in blocking_names) else "do_not_promote" + rationale = "Promotion is blocked by unresolved validation gates: " + ", ".join(blocking_names) + else: + pass4 = pass_records[3] if len(pass_records) >= 4 else {} + policy = pass4.get("workset_path_policy") if isinstance(pass4.get("workset_path_policy"), dict) else {} + if policy.get("allow_creates"): + value = "normalize_run_4" + rationale = "Pass 4 is valid under the explicit API-worker create-file policy; normalize it before any implementation dispatch." + else: + value = "promote_run_2" + rationale = "All gates passed and no create-file normalization is required; pass 2 is the safest directly promotable baseline." + return { + "schema_version": SCHEMA_SELF_PROMOTION, + "cycle_id": gates.get("cycle_id", ""), + "recommendation": value, + "allowed_values": ["promote_run_2", "normalize_run_4", "do_not_promote", "repair_pipeline_first"], + "rationale": rationale, + "implementation_execution": "blocked until explicit operator follow-up", + "written_at": now_iso(), + } + + +def next_cycle_request_payload(seed: dict[str, Any], pass_records: list[dict[str, Any]], gates: dict[str, Any], recommendation: dict[str, Any]) -> dict[str, Any]: + repair = gates.get("status") != "passed" + last_guidance = str((pass_records[-1] if pass_records else {}).get("next_guidance") or "") + objective = ( + "Repair the Cento nightly self-improvement loop before considering promotion." + if repair + else "Continue Cento nightly self-improvement from the validated pass 4 guidance and promotion recommendation." + ) + return { + "schema_version": SCHEMA_SELF_NEXT, + "objective": objective, + "source_cycle": str(gates.get("cycle_id") or ""), + "seed_objective": seed_objective(seed), + "promotion_recommendation": recommendation.get("recommendation"), + "repair_focused": repair, + "required_first_wave": [ + "Resolve any failed blocking validation gates before implementation dispatch.", + "Keep image generation failures as nonblocking evidence unless a backend dependency is explicitly declared.", + "Use `cento workset check WORKSET --runtime api-openai` only for API-worker-created file plans; plain checks remain strict.", + "Prefer Codex/Claude agent lanes for 70-80% of eligible follow-up work when weekly utilization is above 30%.", + "Do not execute implementation worksets automatically from the nightly loop.", + ], + "context_artifacts": [rel(path) for path in run_dir_paths_for_cycle(str(gates.get("cycle_id") or ""))], + "last_pass_guidance": last_guidance, + "budget_model_policy": AGENT_PREFERRED_COMPUTE_POLICY, + "written_at": now_iso(), + } + + +def run_dir_paths_for_cycle(cycle_id: str) -> list[Path]: + if not cycle_id: + return [] + run_dir = SELF_IMPROVE_RUNS_ROOT / cycle_id + return [ + run_dir / "nightly_cycle_manifest.json", + run_dir / "validation_gates.json", + run_dir / "loop_metrics.json", + run_dir / "promotion_recommendation.json", + run_dir / "evidence_handoff.json", + ] + + +def evidence_handoff_payload(run_dir: Path, pass_records: list[dict[str, Any]], gates: dict[str, Any], recommendation: dict[str, Any]) -> dict[str, Any]: + artifacts = [ + "nightly_cycle_manifest.json", + *[f"pass_{index:02d}_child_run_summary.json" for index in range(1, 5)], + "validation_gates.json", + "loop_metrics.json", + "promotion_recommendation.json", + "next_cycle_request.json", + ] + return { + "schema_version": SCHEMA_SELF_HANDOFF, + "cycle_id": run_dir.name, + "status": gates.get("status"), + "promotion_recommendation": recommendation.get("recommendation"), + "run_dir": rel(run_dir), + "latest_dir": rel(self_latest_dir()), + "artifacts": [{"name": name, "path": rel(run_dir / name), "latest_path": rel(self_latest_dir() / name)} for name in artifacts], + "child_runs": [ + { + "pass": record.get("cycle_pass"), + "run_id": record.get("child_run_id"), + "run_dir": record.get("child_run_dir"), + "status": record.get("status"), + "workset": record.get("artifacts", {}).get("parallel_patch_workset") if isinstance(record.get("artifacts"), dict) else "", + } + for record in pass_records + ], + "human_summary": ( + "Nightly loop completed validation and stopped before implementation dispatch. " + f"Promotion recommendation: {recommendation.get('recommendation')}." + ), + "written_at": now_iso(), + } + + +def loop_metrics_payload(run_dir: Path, started: float, pass_records: list[dict[str, Any]]) -> dict[str, Any]: + degraded = [record for record in pass_records if record.get("status") != "completed"] + promotable = [record for record in pass_records if record.get("workset_check", {}).get("status") == "passed"] + return { + "schema_version": SCHEMA_SELF_METRICS, + "cycle_id": run_dir.name, + "duration_seconds": round(time.perf_counter() - started, 3), + "spend_estimate": { + "planning_target_usd": AGENT_PREFERRED_COMPUTE_POLICY["target_spend_usd_max"], + "planning_hard_cap_usd": AGENT_PREFERRED_COMPUTE_POLICY["hard_spend_usd_max"], + "implementation_spend_usd": 0.0, + "note": "No implementation worksets are executed by the nightly loop.", + }, + "pass_statuses": {str(record.get("pass_id")): record.get("status") for record in pass_records}, + "degraded_pass_count": len(degraded), + "workset_promotability": { + "passed_declared_policy_count": len(promotable), + "total": len(pass_records), + }, + "compute_routing_policy": AGENT_PREFERRED_COMPUTE_POLICY, + "written_at": now_iso(), + } + + +def self_cron_block(schedule_time: str) -> str: + hour, minute = parse_cron_time(schedule_time) + log_path = ROOT / "workspace" / "logs" / "ai-self-improvement-nightly.log" + command = ( + f"cd {shlex.quote(str(ROOT))} && " + f"./scripts/cento.sh parallel-delivery self-improve run --json >> {shlex.quote(str(log_path))} 2>&1" + ) + return "\n".join([SELF_CRON_BEGIN, f"{minute} {hour} * * * {command}", SELF_CRON_END, ""]) + + +def parse_cron_time(value: str) -> tuple[int, int]: + try: + hour_text, minute_text = value.split(":", 1) + hour = int(hour_text) + minute = int(minute_text) + except (ValueError, AttributeError) as exc: + raise ValueError("--time must be HH:MM") from exc + if hour < 0 or hour > 23 or minute < 0 or minute > 59: + raise ValueError("--time must be HH:MM using a 24-hour clock") + return hour, minute + + +def strip_self_cron_block(text: str) -> str: + if SELF_CRON_BEGIN not in text: + return text.rstrip() + ("\n" if text.strip() else "") + before, rest = text.split(SELF_CRON_BEGIN, 1) + if SELF_CRON_END not in rest: + return before.rstrip() + "\n" + _block, after = rest.split(SELF_CRON_END, 1) + return (before + after).strip() + ("\n" if (before + after).strip() else "") + + +def read_crontab(crontab_file: str = "") -> str: + if crontab_file: + try: + return Path(crontab_file).read_text(encoding="utf-8") + except FileNotFoundError: + return "" + proc = subprocess.run(["crontab", "-l"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return proc.stdout if proc.returncode == 0 else "" + + +def write_crontab(text: str, crontab_file: str = "") -> None: + if crontab_file: + path = Path(crontab_file) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return + proc = subprocess.run(["crontab", "-"], input=text, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "crontab install failed") + + +def self_e2e_latest_dir() -> Path: + return SELF_IMPROVE_E2E_RUNS_ROOT / "latest" + + +def resolve_self_e2e_run_dir(value: str | None, *, create: bool = False) -> Path: + if value: + path = Path(value) + if not path.is_absolute() and ("/" not in value and "\\" not in value): + path = SELF_IMPROVE_E2E_RUNS_ROOT / value + elif not path.is_absolute(): + path = ROOT / path + else: + path = SELF_IMPROVE_E2E_RUNS_ROOT / f"self-improve-e2e-{now_stamp()}" + if create: + path.mkdir(parents=True, exist_ok=True) + return path + + +def mirror_self_e2e_latest(run_dir: Path) -> None: + latest = self_e2e_latest_dir() + if latest.exists() or latest.is_symlink(): + shutil.rmtree(latest) + shutil.copytree(run_dir, latest) + + +def jsonl_rows(path: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return rows + for line in lines: + if not line.strip(): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + rows.append(payload) + return rows + + +def self_e2e_source_payload(run_dir: Path, *, fixture_only: bool) -> dict[str, Any]: + latest_next = self_latest_dir() / "next_cycle_request.json" + latest_payload = read_json(latest_next) + if latest_payload: + payload = { + "schema_version": "cento.ai_self_improvement_e2e.source.v1", + "status": "ready", + "source": "latest_next_cycle_request", + "path": rel(latest_next), + "request": latest_payload, + "planning_loop": {"status": "skipped_existing_latest"}, + "written_at": now_iso(), + } + write_json(run_dir / "self_improve_source.json", payload) + return payload + if fixture_only: + seed = resolve_self_seed() + payload = { + "schema_version": "cento.ai_self_improvement_e2e.source.v1", + "status": "ready", + "source": "fixture_seed_without_latest", + "path": seed.get("path", ""), + "request": seed.get("request") if isinstance(seed.get("request"), dict) else {}, + "source_payload": seed.get("source_payload") if isinstance(seed.get("source_payload"), dict) else {}, + "planning_loop": {"status": "skipped_fixture_only"}, + "written_at": now_iso(), + } + write_json(run_dir / "self_improve_source.json", payload) + return payload + + planning_run_dir = SELF_IMPROVE_RUNS_ROOT / f"{run_dir.name}-planning" + command = [ + "./scripts/cento.sh", + "parallel-delivery", + "self-improve", + "run", + "--run-dir", + rel(planning_run_dir), + "--quiet", + "--json", + ] + proc = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + latest_payload = read_json(latest_next) + payload = { + "schema_version": "cento.ai_self_improvement_e2e.source.v1", + "status": "ready" if latest_payload else "blocked", + "source": "planning_loop_generated_latest" if latest_payload else "planning_loop_failed_without_next_cycle_request", + "path": rel(latest_next) if latest_payload else "", + "request": latest_payload, + "planning_loop": { + "status": "completed" if proc.returncode == 0 else "blocked", + "command": command, + "exit_code": proc.returncode, + "stdout_tail": proc.stdout[-4000:], + "stderr_tail": proc.stderr[-4000:], + "run_dir": rel(planning_run_dir), + }, + "written_at": now_iso(), + } + write_json(run_dir / "self_improve_source.json", payload) + return payload + + +def patch_swarm_spend_summary(run_dir: Path) -> dict[str, Any]: + rows = jsonl_rows(run_dir / "candidate_spend_ledger.jsonl") + provider_counts = Counter(str(item.get("provider") or "unknown") for item in rows) + provider_costs = { + provider: round(sum(float(item.get("cost_usd_estimate") or 0.0) for item in rows if str(item.get("provider") or "unknown") == provider), 6) + for provider in sorted(provider_counts) + } + return { + "schema_version": "cento.ai_self_improvement_e2e.spend_summary.v1", + "patch_swarm_run_id": run_dir.name, + "candidate_rows": len(rows), + "total_estimated_spend_usd": round(sum(float(item.get("cost_usd_estimate") or 0.0) for item in rows), 6), + "provider_counts": dict(sorted(provider_counts.items())), + "provider_costs_usd": provider_costs, + "usage_guard": rel(run_dir / "usage_guard.json") if (run_dir / "usage_guard.json").exists() else "", + "provider_usage": rel(run_dir / "provider_usage.jsonl") if (run_dir / "provider_usage.jsonl").exists() else "", + "candidate_spend_ledger": rel(run_dir / "candidate_spend_ledger.jsonl") if (run_dir / "candidate_spend_ledger.jsonl").exists() else "", + "written_at": now_iso(), + } + + +def auto_merge_environment_blocked(receipt: dict[str, Any]) -> bool: + if receipt.get("status") != "blocked" or receipt.get("push_requested"): + return False + blockers = {str(item) for item in receipt.get("blockers") or []} + environment_prefixes = ("current_branch_not_",) + environment_blockers = {"main_worktree_dirty", "integration_worktree_missing", "integration_branch_missing"} + return bool(blockers) and all(item in environment_blockers or item.startswith(environment_prefixes) for item in blockers) + + +def run_self_e2e_auto_merge_gate(factory_run_dir: Path) -> dict[str, Any]: + command = [ + "./scripts/cento.sh", + "factory", + "merge", + rel(factory_run_dir), + "--auto-merge-main", + "--dry-run", + "--json", + ] + proc = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + try: + receipt = json.loads(proc.stdout) if proc.stdout.strip() else {} + except json.JSONDecodeError: + receipt = {} + return { + "schema_version": "cento.ai_self_improvement_e2e.auto_merge_gate.v1", + "status": receipt.get("status", "blocked"), + "command": command, + "exit_code": proc.returncode, + "dry_run": True, + "push_requested": bool(receipt.get("push_requested", False)), + "receipt": receipt, + "receipt_path": rel(factory_run_dir / "integration" / "merge-receipt.json"), + "stdout_tail": proc.stdout[-4000:], + "stderr_tail": proc.stderr[-4000:], + "written_at": now_iso(), + } + + +def safe_integrator_apply_summary(factory_run_dir: Path, promotion: dict[str, Any]) -> dict[str, Any]: + integration_dir = factory_run_dir / "integration" + applied = read_json(integration_dir / "applied-patches.json") + rejected = read_json(integration_dir / "rejected-patches.json") + return { + "schema_version": "cento.ai_self_improvement_e2e.safe_integrator_apply.v1", + "status": "applied" if int(promotion.get("applied_count") or 0) > 0 else str(promotion.get("status") or "ready_for_apply"), + "apply_requested": bool(promotion.get("apply")), + "factory_run_dir": rel(factory_run_dir), + "apply_plan": rel(integration_dir / "apply-plan.json") if (integration_dir / "apply-plan.json").exists() else "", + "validation_fanout": rel(integration_dir / "validation-fanout.json") if (integration_dir / "validation-fanout.json").exists() else "", + "applied_patches": rel(integration_dir / "applied-patches.json") if applied else "", + "rejected_patches": rel(integration_dir / "rejected-patches.json") if rejected else "", + "applied_count": len(applied.get("patches") or []), + "rejected_count": len(rejected.get("patches") or []), + "release_candidate": str(promotion.get("release_candidate") or ""), + "written_at": now_iso(), + } + + +def self_e2e_validation_summary( + run_dir: Path, + *, + source: dict[str, Any], + patch_receipt: dict[str, Any], + integration: dict[str, Any], + patch_validation: dict[str, Any], + promotion: dict[str, Any], + safe_apply: dict[str, Any], + auto_gate: dict[str, Any], + status: str, +) -> dict[str, Any]: + checks = [ + {"name": "self_improve_source", "status": "passed" if source.get("status") == "ready" else "failed", "detail": source.get("source", "")}, + {"name": "patch_swarm_candidates", "status": "passed" if patch_receipt.get("status") == "candidates_generated" else "failed", "detail": str(patch_receipt.get("errors") or "")}, + {"name": "patch_swarm_integration", "status": "passed" if integration.get("status") == "completed" else "failed", "detail": str(integration.get("blockers") or "")}, + {"name": "patch_swarm_validation", "status": "passed" if patch_validation.get("status") == "passed" else "failed", "detail": patch_validation.get("status", "")}, + {"name": "factory_promotion", "status": "passed" if promotion.get("status") in {"ready_for_apply", "release_candidate_ready", "planned"} else "failed", "detail": str(promotion.get("status") or "")}, + {"name": "safe_integrator_apply", "status": "passed" if safe_apply.get("status") in {"ready_for_apply", "applied", "release_candidate_ready"} else "failed", "detail": str(safe_apply.get("status") or "")}, + {"name": "auto_merge_gate_no_push", "status": "passed" if not auto_gate or auto_gate.get("push_requested") is False else "failed", "detail": str(auto_gate.get("status") or "skipped")}, + ] + return { + "schema_version": SCHEMA_SELF_E2E_VALIDATION, + "run_id": run_dir.name, + "status": "passed" if status in {"ready_for_apply", "applied", "auto_merge_blocked_by_environment"} else "blocked", + "e2e_status": status, + "checks": checks, + "written_at": now_iso(), + } + + +def write_self_e2e_handoff(run_dir: Path, payload: dict[str, Any]) -> None: + lines = [ + "# AI Self-Improvement Autopilot E2E Handoff", + "", + f"- Run: `{payload.get('run_id')}`", + f"- Status: `{payload.get('status')}`", + f"- Patch Swarm: `{payload.get('patch_swarm_run_dir')}`", + f"- Factory run: `{payload.get('factory_run_dir') or '-'}`", + f"- Spend summary: `{rel(run_dir / 'spend_summary.json')}`", + f"- Validation: `{rel(run_dir / 'validation_summary.json')}`", + f"- Auto-merge gate: `{rel(run_dir / 'auto_merge_gate.json')}`", + "", + "No merge or push to main was performed. The auto-merge gate is dry-run only.", + "", + ] + (run_dir / "handoff.md").write_text("\n".join(lines), encoding="utf-8") + + +def command_plan(args: argparse.Namespace) -> int: + run_dir = resolve_run_dir(args.run_dir, create=True) + manifest = write_plan(run_dir) + write_demo_workset(run_dir) + result = {"status": "planned", "run_dir": rel(run_dir), "implementation_manifest": rel(run_dir / "implementation_manifest.json"), "pass_count": len(manifest["hard_proreq_passes"])} + print(json.dumps(result, indent=2) if args.json else f"planned {rel(run_dir)}") + return 0 + + +def command_execute(args: argparse.Namespace) -> int: + run_dir = resolve_run_dir(args.run_dir, create=True) + manifest = read_json(run_dir / "implementation_manifest.json") or write_plan(run_dir) + passes = manifest.get("hard_proreq_passes") if isinstance(manifest.get("hard_proreq_passes"), list) else [] + if args.only: + wanted = set(args.only.split(",")) + passes = [item for item in passes if item.get("id") in wanted] + if args.max_passes: + passes = passes[: args.max_passes] + records: list[dict[str, Any]] = [] + for index, pass_spec in enumerate(passes, start=1): + print(f"parallel-delivery proreq {index}/{len(passes)} {pass_spec['id']}", flush=True) + record = execute_proreq_pass(pass_spec, args) + records.append(record) + partial = { + "schema_version": SCHEMA_RECEIPT, + "written_at": now_iso(), + "status": "running", + "run_dir": rel(run_dir), + "expected_pass_count": len(passes), + "total_workstream_count": len(WORKSTREAMS), + "selected_pass_ids": [str(item.get("id") or "") for item in passes], + "demo_required": not args.skip_demo, + "passes": records, + } + write_json(run_dir / "proreq_receipt.partial.json", partial) + if args.sleep_seconds > 0 and index < len(passes): + time.sleep(args.sleep_seconds) + demo_receipt = None + if not args.skip_demo: + print("parallel-delivery demo", flush=True) + demo_receipt = run_demo(run_dir, execute=True) + all_completed = bool(records) and all(item.get("status") == "completed" and item.get("workset_check", {}).get("status") == "passed" for item in records) + if demo_receipt and demo_receipt.get("status") != "completed": + all_completed = False + receipt = { + "schema_version": SCHEMA_RECEIPT, + "written_at": now_iso(), + "status": "completed" if all_completed else "failed", + "run_dir": rel(run_dir), + "implementation_manifest": rel(run_dir / "implementation_manifest.json"), + "expected_pass_count": len(passes), + "total_workstream_count": len(WORKSTREAMS), + "selected_pass_ids": [str(item.get("id") or "") for item in passes], + "demo_required": not args.skip_demo, + "live_policy": { + "openai_api_key_present": bool(os.environ.get("OPENAI_API_KEY")), + "live_pro_requested": bool(args.live_pro), + "reference_screenshot": args.reference_screenshot, + }, + "passes": records, + "demo_receipt": rel(run_dir / "demo" / "demo_receipt.json") if demo_receipt else "", + } + write_json(run_dir / "proreq_receipt.json", receipt) + compose_execution_manifest(run_dir, receipt, demo_receipt) + validation = validate_run(run_dir) + result = {"status": receipt["status"], "run_dir": rel(run_dir), "receipt": rel(run_dir / "proreq_receipt.json"), "validation": validation["status"]} + print(json.dumps(result, indent=2) if args.json else f"{receipt['status']} {rel(run_dir)}") + return 0 if receipt["status"] == "completed" and validation["status"] == "passed" else 1 + + +def command_demo(args: argparse.Namespace) -> int: + run_dir = resolve_run_dir(args.run_dir, create=True) + if not (run_dir / "implementation_manifest.json").exists(): + write_plan(run_dir) + receipt = run_demo(run_dir, execute=not args.plan_only) + print(json.dumps({"run_dir": rel(run_dir), "demo": receipt}, indent=2) if args.json else f"demo {receipt['status']} {rel(run_dir)}") + return 0 if receipt["status"] in {"completed", "planned"} else 1 + + +def command_validate(args: argparse.Namespace) -> int: + run_dir = resolve_validation_run_dir(args.run_dir) + payload = validate_selected_run(run_dir) + print(json.dumps({"run_dir": rel(run_dir), **payload}, indent=2) if args.json else f"{payload['status']} {rel(run_dir)}") + return 0 if payload["status"] == "passed" else 1 + + +def command_status(args: argparse.Namespace) -> int: + if getattr(args, "run", ""): + run_root_value = Path(getattr(args, "run_root", "") or RUNS_ROOT) + run_root = run_root_value if run_root_value.is_absolute() else ROOT / run_root_value + run_dir = run_root / str(args.run) + else: + run_dir = resolve_validation_run_dir(args.run_dir) + payload = status_for_selected_run(run_dir) + print(json.dumps(payload, indent=2) if args.json else f"{payload['status']} {payload['validation']} {payload['run_dir']}") + return 0 + + +def command_train_plan(args: argparse.Namespace) -> int: + run_dir = resolve_train_run_dir(args.run_id, create=True) + source = Path(args.workset) + if not source.is_absolute(): + source = ROOT / source + manifest = build_train_artifacts(source, run_dir, max_parallel=args.max_parallel) + payload = { + "status": manifest.get("status"), + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "train_manifest": rel(run_dir / "train_manifest.json"), + "integration_queue": rel(run_dir / "integration_queue.json"), + "decision_report": rel(run_dir / "decision_report.md"), + } + print(json.dumps(payload, indent=2) if args.json else f"{payload['status']} {payload['run_dir']}") + return 0 if manifest.get("status") == "planned" else 1 + + +def command_train_run(args: argparse.Namespace) -> int: + simulate = bool(getattr(args, "simulate", False)) + workset_execute = bool(getattr(args, "workset_execute", False)) + if not simulate and not workset_execute: + print("parallel-delivery train run requires --simulate or --workset-execute.", file=sys.stderr) + return 2 + run_dir = resolve_train_run_dir(args.run_id) + if workset_execute: + receipt = execute_train_workset( + run_dir, + runtime=getattr(args, "runtime", "fixture"), + runtime_profile=getattr(args, "runtime_profile", "") or "", + api_profile=getattr(args, "api_profile", "") or "", + api_config=getattr(args, "api_config", "") or "", + budget_usd=getattr(args, "budget_usd", None), + max_budget_usd=getattr(args, "max_budget_usd", None), + validation=getattr(args, "validation", "smoke") or "", + worker_timeout=getattr(args, "worker_timeout", None), + retry_attempts=getattr(args, "retry_attempts", None), + fixture_case=getattr(args, "fixture_case", "valid") or "valid", + allow_dirty_owned=bool(getattr(args, "allow_dirty_owned", False)), + allow_creates=bool(getattr(args, "allow_creates", False)), + ) + else: + receipt = simulate_train_workers(run_dir) + payload = {"run_id": run_dir.name, "run_dir": rel(run_dir), "receipt": rel(run_dir / "train_receipt.json"), **receipt} + print(json.dumps(payload, indent=2) if args.json else f"{receipt['status']} {rel(run_dir)}") + return 0 if receipt.get("status") in {"workers_simulated", "workset_completed"} else 1 + + +def command_train_integrate(args: argparse.Namespace) -> int: + if not args.dry_run: + print("parallel-delivery train integrate v1 requires --dry-run; patch apply is not implemented in this MVP.", file=sys.stderr) + return 2 + run_dir = resolve_train_run_dir(args.run_id) + receipt = dry_run_train_integration(run_dir) + payload = {"run_id": run_dir.name, "run_dir": rel(run_dir), "receipt": rel(run_dir / "train_receipt.json"), **receipt} + print(json.dumps(payload, indent=2) if args.json else f"{receipt['status']} {rel(run_dir)}") + return 0 if receipt.get("status") == "integration_planned" else 1 + + +def command_train_status(args: argparse.Namespace) -> int: + run_dir = resolve_train_run_dir(args.run_id) + manifest = read_json(run_dir / "train_manifest.json") + receipt = read_json(run_dir / "train_receipt.json") + validation = read_json(run_dir / "validation_summary.json") + queue = read_json(run_dir / "integration_queue.json") + promotion = read_json(run_dir / "promotion_decision.json") + items = queue.get("items") if isinstance(queue.get("items"), list) else [] + payload = { + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "status": receipt.get("status") or manifest.get("status", "unknown"), + "validation": validation.get("status", "unknown"), + "max_parallel": manifest.get("max_parallel", 0), + "queue_count": len(items), + "train_manifest": rel(run_dir / "train_manifest.json"), + "decision_report": rel(run_dir / "decision_report.md"), + "workset_receipt": receipt.get("workset_receipt", ""), + "promotion": promotion.get("decision", "unknown"), + "factory_run_dir": promotion.get("factory_run_dir", ""), + } + print(json.dumps(payload, indent=2) if args.json else f"{payload['status']} {payload['validation']} {payload['run_dir']}") + return 0 + + +def command_train_validate(args: argparse.Namespace) -> int: + run_dir = resolve_train_run_dir(args.run_id) + payload = validate_train_run(run_dir) + result = {"run_id": run_dir.name, "run_dir": rel(run_dir), **payload} + print(json.dumps(result, indent=2) if args.json else f"{payload['status']} {rel(run_dir)}") + return 0 if payload["status"] == "passed" else 1 + + +def command_train_promote(args: argparse.Namespace) -> int: + if getattr(args, "apply", False) and getattr(args, "dry_run", False): + print("parallel-delivery train promote accepts either --dry-run or --apply, not both.", file=sys.stderr) + return 2 + run_dir = resolve_train_run_dir(args.run_id) + decision = promote_train_run( + run_dir, + apply=bool(getattr(args, "apply", False)), + validate_each=bool(getattr(args, "validate_each", False)), + branch=getattr(args, "branch", "") or "", + worktree=getattr(args, "worktree", "") or "", + limit=int(getattr(args, "limit", 0) or 0), + ) + payload = {"run_id": run_dir.name, "run_dir": rel(run_dir), **decision} + print(json.dumps(payload, indent=2) if args.json else f"{decision['status']} {decision['decision']} {rel(run_dir)}") + return 0 if decision.get("status") in {"planned", "completed"} else 1 + + +def command_train_e2e(args: argparse.Namespace) -> int: + if getattr(args, "apply", False) and getattr(args, "dry_run", False): + print("parallel-delivery train e2e accepts either --dry-run or --apply, not both.", file=sys.stderr) + return 2 + run_id = args.run_id or f"train-e2e-{now_stamp()}" + run_dir = resolve_train_run_dir(run_id, create=True) + source = Path(args.workset) + if not source.is_absolute(): + source = ROOT / source + manifest = build_train_artifacts(source, run_dir, max_parallel=args.max_parallel) + receipt: dict[str, Any] = {} + validation: dict[str, Any] = {} + promotion: dict[str, Any] = {} + if manifest.get("status") == "planned": + receipt = execute_train_workset( + run_dir, + runtime=getattr(args, "runtime", "fixture"), + runtime_profile=getattr(args, "runtime_profile", "") or "", + api_profile=getattr(args, "api_profile", "") or "", + api_config=getattr(args, "api_config", "") or "", + budget_usd=getattr(args, "budget_usd", None), + max_budget_usd=getattr(args, "max_budget_usd", None), + validation=getattr(args, "validation", "smoke") or "", + worker_timeout=getattr(args, "worker_timeout", None), + retry_attempts=getattr(args, "retry_attempts", None), + fixture_case=getattr(args, "fixture_case", "valid") or "valid", + allow_dirty_owned=bool(getattr(args, "allow_dirty_owned", False)), + allow_creates=bool(getattr(args, "allow_creates", False)), + ) + validation = validate_train_run(run_dir) + if receipt.get("status") == "workset_completed": + promotion = promote_train_run( + run_dir, + apply=bool(getattr(args, "apply", False)), + validate_each=bool(getattr(args, "validate_each", False)), + branch=getattr(args, "branch", "") or "", + worktree=getattr(args, "worktree", "") or "", + limit=int(getattr(args, "limit", 0) or 0), + ) + status = "completed" if promotion.get("status") in {"planned", "completed"} and validation.get("status") == "passed" else "blocked" + payload = { + "status": status, + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "train_manifest": rel(run_dir / "train_manifest.json"), + "workset_receipt": receipt.get("workset_receipt", ""), + "validation": validation.get("status", "unknown"), + "promotion": promotion.get("decision", "unknown"), + "factory_run_dir": promotion.get("factory_run_dir", ""), + "promotion_decision": rel(run_dir / "promotion_decision.json") if (run_dir / "promotion_decision.json").exists() else "", + "release_candidate": promotion.get("release_candidate", ""), + } + print(json.dumps(payload, indent=2) if args.json else f"{status} {payload['promotion']} {rel(run_dir)}") + return 0 if status == "completed" else 1 + + +def command_patch_swarm_plan(args: argparse.Namespace) -> int: + run_id = args.run_id or f"patch-swarm-{now_stamp()}" + run_dir = resolve_patch_swarm_run_dir(run_id, create=True) + manifest = build_patch_swarm_plan( + run_dir, + objective=getattr(args, "objective", "") or PATCH_SWARM_OBJECTIVE, + candidate_target=getattr(args, "candidate_target", 100), + max_parallel_agents=getattr(args, "max_parallel_agents", 5), + providers=patch_swarm_provider_list(getattr(args, "providers", "")), + live=bool(getattr(args, "live", False)), + ) + payload = { + "status": manifest.get("status"), + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "candidate_target": manifest.get("candidate_target"), + "proreq_execution_count": manifest.get("proreq_execution_count"), + "manifest": rel(run_dir / "patch_swarm_manifest.json"), + "ui_state": rel(run_dir / "ui_state.json"), + } + print(json.dumps(payload, indent=2) if args.json else f"{payload['status']} {payload['run_dir']}") + return 0 + + +def command_patch_swarm_split(args: argparse.Namespace) -> int: + payload, code = planner_tool.run_from_args(args, command="parallel-delivery patch-swarm split") + if args.json: + print(planner_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"{payload.get('state')} {payload.get('candidate_count')} tasks {payload.get('run_dir')}") + else: + print("; ".join(payload.get("errors", ["split plan failed"])), file=sys.stderr) + return code + + +def command_patch_swarm_leases(args: argparse.Namespace) -> int: + payload, code = lease_tool.run_create(args, command="parallel-delivery patch-swarm leases") + if args.json: + print(lease_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"leases {payload.get('run_id')} {payload.get('run_dir')}") + else: + print("; ".join(payload.get("errors", ["lease generation failed"])), file=sys.stderr) + return code + + +def command_patch_swarm_validate_leases(args: argparse.Namespace) -> int: + payload, code = lease_tool.run_validate(args) + if args.json: + print(lease_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"lease validation passed {payload.get('run_id')} {payload.get('run_dir', '')}") + else: + print("; ".join(payload.get("errors", ["lease validation failed"])), file=sys.stderr) + return code + + +def command_patch_bundles_validate(args: argparse.Namespace) -> int: + payload, code = patch_bundles_tool.run_validate_from_args(args) + if args.json: + print(patch_bundles_tool.stable_json_dumps(payload), end="") + elif payload.get("validation_status") == "accepted": + print(f"accepted {payload.get('bundle_id')} {payload.get('receipt_id')}") + else: + print("; ".join(payload.get("reason_codes") or ["patch bundle rejected"]), file=sys.stderr) + return code + + +def command_patch_bundles_collect(args: argparse.Namespace) -> int: + payload, code = patch_bundles_tool.run_collect_from_args(args) + if args.json: + print(patch_bundles_tool.stable_json_dumps(payload), end="") + else: + print( + "patch bundles " + f"accepted={payload.get('accepted_count')} " + f"rejected={payload.get('rejected_count')} " + f"report={Path(args.out) / 'patch-bundle-report.json'}" + ) + return code + + +def command_patch_swarm_prompts(args: argparse.Namespace) -> int: + payload, code = prompts_tool.run_generate_from_args(args, command="parallel-delivery patch-swarm prompts") + if args.json: + print(prompts_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"{payload.get('state')} {payload.get('prompt_count')} prompts {payload.get('run_dir')}") + else: + print("; ".join(payload.get("errors", ["prompt bundle failed"])), file=sys.stderr) + return code + + +def command_patch_swarm_worker_packets(args: argparse.Namespace) -> int: + try: + if getattr(args, "fixture", False): + result = codex_packets_tool.build_codex_packets_fixture( + Path(args.run_dir), + run_id=args.run_id or "codex-packets-fixture", + count=int(args.count or codex_packets_tool.DEFAULT_PACKET_COUNT), + timestamp=args.fixed_timestamp or "2026-01-01T00:00:00Z", + ) + else: + result = codex_packets_tool.write_packet_bundle( + codex_packets_tool.CodexPacketRequest( + run_id=args.run_id, + run_dir=Path(args.run_dir), + count=args.count, + fixed_timestamp=args.fixed_timestamp or None, + ) + ) + payload = codex_packets_tool.result_payload(result) + code = 0 if payload.get("ok") else 1 + except codex_packets_tool.CodexPacketError as exc: + payload = { + "ok": False, + "run_id": args.run_id or Path(args.run_dir).name, + "packet_count": 0, + "errors": [str(exc)], + "warnings": [], + } + code = 1 + if args.json: + print(codex_packets_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"codex packets {payload.get('packet_count')} {payload.get('run_dir')}") + else: + print("; ".join(payload.get("errors", ["worker packet generation failed"])), file=sys.stderr) + return code + + +def command_patch_swarm_dispatch(args: argparse.Namespace) -> int: + payload = worker_status_tool.plan_dispatch( + Path(args.run_dir), + run_id=getattr(args, "run_id", "") or None, + candidate_target=int(getattr(args, "candidate_target", worker_status_tool.MAX_CANDIDATE_TASKS) or worker_status_tool.MAX_CANDIDATE_TASKS), + max_parallel_agents=int(getattr(args, "max_parallel_agents", 5) or 5), + dry_run=bool(getattr(args, "dry_run", True)), + live=bool(getattr(args, "live", False)), + timestamp=getattr(args, "fixed_timestamp", "") or None, + fixture=bool(getattr(args, "fixture", False)), + ) + code = 0 if payload.get("ok") else 1 + if args.json: + print(worker_status_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"worker dispatch dry-run {payload.get('candidate_tasks')} tasks {payload.get('run_dir')}") + else: + print("; ".join(payload.get("errors", ["worker dispatch planning failed"])), file=sys.stderr) + return code + + +def command_patch_swarm_worker_status(args: argparse.Namespace) -> int: + try: + payload = worker_status_tool.status_for_run(Path(args.run_dir)) + code = 0 if payload.get("ok") else 1 + except worker_status_tool.WorkerStatusError as exc: + payload = { + "ok": False, + "run_id": Path(args.run_dir).name, + "run_dir": args.run_dir, + "errors": [str(exc)], + "warnings": [], + } + code = 1 + if args.json: + print(worker_status_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"worker status {payload.get('candidate_tasks')} tasks {payload.get('run_dir')}") + else: + print("; ".join(payload.get("errors", ["worker status unavailable"])), file=sys.stderr) + return code + + +def command_release_candidate_create(args: argparse.Namespace) -> int: + payload, code = release_candidate_tool.run_create_from_args(args) + if args.json: + print(release_candidate_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"{payload.get('status')} {payload.get('release_candidate') or payload.get('out')}") + else: + print(str(payload.get("error") or "release candidate creation failed"), file=sys.stderr) + return code + + +def command_taskstream_emit(args: argparse.Namespace) -> int: + payload, code = taskstream_tool.run_emit_from_args(args) + if args.json: + print(taskstream_tool.stable_json_dumps(payload), end="") + elif code == 0: + print( + "taskstream handoff " + f"tasks={payload.get('task_count')} " + f"agent_work={payload.get('agent_work_routed_count')} " + f"manifest_only={payload.get('manifest_only_count')} " + f"report={Path(args.out) / 'taskstream-handoff-report.json'}" + ) + else: + print("; ".join(payload.get("errors", ["taskstream emit failed"])), file=sys.stderr) + return code + + +def command_taskstream_preflight(args: argparse.Namespace) -> int: + payload, code = taskstream_tool.run_preflight_from_args(args) + if args.json: + print(taskstream_tool.stable_json_dumps(payload), end="") + elif code == 0: + print(f"taskstream preflight passed {payload.get('manifest_dir')}") + else: + print("; ".join(payload.get("errors") or ["taskstream preflight blocked"]), file=sys.stderr) + return code + + +def command_taskstream_apply(args: argparse.Namespace) -> int: + payload, code = taskstream_tool.run_apply_from_args(args) + if args.json: + print(taskstream_tool.stable_json_dumps(payload), end="") + elif code == 0: + print(f"taskstream apply receipts={len(payload.get('receipts') or [])}") + else: + print("; ".join(payload.get("errors", ["taskstream apply failed"])), file=sys.stderr) + return code + + +def command_patch_swarm_execute(args: argparse.Namespace) -> int: + run_dir = resolve_patch_swarm_run_dir(args.run_id) + receipt = execute_patch_swarm( + run_dir, + fixture=bool(getattr(args, "fixture", False) or not getattr(args, "live", False)), + budget_cap_usd=getattr(args, "budget_cap_usd", None), + max_budget_usd=getattr(args, "max_budget_usd", None), + api_sandbox_candidates=int(getattr(args, "api_sandbox_candidates", 1) or 0), + api_profile=getattr(args, "api_profile", PATCH_SWARM_API_PROFILE), + api_config=getattr(args, "api_config", str(ROOT / ".cento" / "api_workers.yaml")), + ) + payload = {"run_id": run_dir.name, "run_dir": rel(run_dir), **receipt} + print(json.dumps(payload, indent=2) if args.json else f"{receipt.get('status')} {rel(run_dir)}") + return 0 if receipt.get("status") == "candidates_generated" else 1 + + +def command_patch_swarm_integrate(args: argparse.Namespace) -> int: + run_dir = resolve_patch_swarm_run_dir(args.run_id) + integration = integrate_patch_swarm( + run_dir, + apply=bool(getattr(args, "apply", False)), + factory_run=getattr(args, "factory_run", ""), + validate_each=bool(getattr(args, "validate_each", False)), + branch=getattr(args, "branch", ""), + worktree=getattr(args, "worktree", ""), + limit=int(getattr(args, "limit", 0) or 0), + ) + payload = {"run_id": run_dir.name, "run_dir": rel(run_dir), **integration} + print(json.dumps(payload, indent=2) if args.json else f"{integration.get('status')} {rel(run_dir)}") + return 0 if integration.get("status") == "completed" else 1 + + +def command_patch_swarm_validate(args: argparse.Namespace) -> int: + run_dir = resolve_patch_swarm_run_dir(args.run_id) + validation = validate_patch_swarm_run(run_dir) + payload = {"run_id": run_dir.name, "run_dir": rel(run_dir), **validation} + print(json.dumps(payload, indent=2) if args.json else f"{validation.get('status')} {rel(run_dir)}") + return 0 if validation.get("status") == "passed" else 1 + + +def command_patch_swarm_status(args: argparse.Namespace) -> int: + console_mode = bool( + getattr(args, "run_dir", "") + or getattr(args, "output_dir", "") + or getattr(args, "write_html", False) + or getattr(args, "strict_links", False) + or (getattr(args, "run_id", "") and ("/" in getattr(args, "run_id", "") or "\\" in getattr(args, "run_id", ""))) + ) + if console_mode: + raw_run_dir = getattr(args, "run_dir", "") or "" + if raw_run_dir: + run_dir = Path(raw_run_dir) + elif getattr(args, "run_id", "") and ("/" in args.run_id or "\\" in args.run_id): + run_dir = Path(args.run_id) + elif getattr(args, "run_id", ""): + run_dir = resolve_patch_swarm_run_dir(args.run_id) + else: + run_dir = latest_patch_swarm_fixture_e2e_run_dir() or patch_swarm_latest_run_dir() or RUNS_ROOT + output_dir = Path(args.output_dir) if getattr(args, "output_dir", "") else None + try: + console_data, metadata = patch_swarm_console_tool.render_console( + run_dir, + output_dir=output_dir, + write_html=bool(getattr(args, "write_html", False)), + strict_links=bool(getattr(args, "strict_links", False)), + ) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + if args.json: + print( + patch_swarm_console_tool.stable_json( + patch_swarm_console_tool.emit_console_json(console_data, output_dir=output_dir or run_dir), + pretty=False, + ), + end="", + ) + else: + target = metadata.get("start_here") or metadata.get("console_data") or console_data.run_dir + print(f"{console_data.current_run.get('result', 'unknown')} {console_data.candidate_count} candidates {target}") + return 0 + + run_dir = resolve_patch_swarm_run_dir(args.run_id) + manifest = read_json(run_dir / "patch_swarm_manifest.json") + receipt = read_json(run_dir / "patch_swarm_receipt.json") + integration = read_json(run_dir / "integration_execution" / "integration_execution.json") + validation = read_json(run_dir / "validation_summary.json") + ui_state = read_json(run_dir / "ui_state.json") + payload = { + "schema_version": "cento.patch_swarm.status.v1", + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "status": validation.get("status") or integration.get("status") or receipt.get("status") or manifest.get("status", "unknown"), + "candidate_target": manifest.get("candidate_target", 0), + "candidate_count": receipt.get("candidate_count", 0), + "proreq_execution_count": manifest.get("proreq_execution_count", 0), + "selected_count": integration.get("selected_count", 0), + "providers": manifest.get("providers", []), + "estimated_cost_usd": receipt.get("estimated_cost_usd", 0.0), + "validation": validation.get("status", "unknown"), + "ui_state": rel(run_dir / "ui_state.json") if ui_state else "", + "decision_report": rel(run_dir / "decision_report.md") if (run_dir / "decision_report.md").exists() else "", + } + print(json.dumps(payload, indent=2) if args.json else f"{payload['status']} {payload['candidate_count']} candidates {payload['run_dir']}") + return 0 + + +def command_patch_swarm_e2e(args: argparse.Namespace) -> int: + if hasattr(args, "run_root") and not bool(getattr(args, "live", False)) and not bool(getattr(args, "apply", False)): + payload, code = validation_e2e_tool.run_from_args(args, command="parallel-delivery patch-swarm e2e") + if args.json: + print(validation_e2e_tool.stable_json_dumps(payload), end="") + elif payload.get("ok"): + print(f"{payload.get('state')} {payload.get('candidate_count')} tasks {payload.get('run_dir')}") + else: + print("; ".join(payload.get("errors", ["fixture e2e failed"])), file=sys.stderr) + return code + + run_id = args.run_id or f"patch-swarm-e2e-{now_stamp()}" + run_dir = resolve_patch_swarm_run_dir(run_id, create=True) + planner_fixture_dir = run_dir.parent.parent / "planner-fixture" + planner_payload, planner_code = planner_tool.run_planner_command( + candidate_target=int(getattr(args, "candidate_target", 100) or 100), + command="parallel-delivery patch-swarm e2e", + dry_run=False, + live_pro=bool(getattr(args, "live", False)), + max_parallel_agents=int(getattr(args, "max_parallel_agents", 5) or 5), + mode="fixture" if bool(getattr(args, "fixture", False) or not getattr(args, "live", False)) else "no-model", + request_text=getattr(args, "objective", "") or PATCH_SWARM_OBJECTIVE, + run_dir=planner_fixture_dir, + run_id="planner-fixture", + ) + if planner_code != 0: + payload = { + "status": "blocked", + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "planner": planner_payload, + "errors": planner_payload.get("errors", []), + } + print(json.dumps(payload, indent=2) if args.json else f"blocked {rel(run_dir)}") + return planner_code + manifest = build_patch_swarm_plan( + run_dir, + objective=getattr(args, "objective", "") or PATCH_SWARM_OBJECTIVE, + candidate_target=getattr(args, "candidate_target", 100), + max_parallel_agents=getattr(args, "max_parallel_agents", 5), + providers=patch_swarm_provider_list(getattr(args, "providers", "")), + live=bool(getattr(args, "live", False)), + ) + receipt = execute_patch_swarm( + run_dir, + fixture=bool(getattr(args, "fixture", False) or not getattr(args, "live", False)), + budget_cap_usd=getattr(args, "budget_cap_usd", None), + max_budget_usd=getattr(args, "max_budget_usd", None), + api_sandbox_candidates=int(getattr(args, "api_sandbox_candidates", 1) or 0), + api_profile=getattr(args, "api_profile", PATCH_SWARM_API_PROFILE), + api_config=getattr(args, "api_config", str(ROOT / ".cento" / "api_workers.yaml")), + ) + integration: dict[str, Any] = {} + validation: dict[str, Any] = {} + if receipt.get("status") == "candidates_generated": + integration = integrate_patch_swarm( + run_dir, + apply=bool(getattr(args, "apply", False)), + factory_run=getattr(args, "factory_run", ""), + validate_each=bool(getattr(args, "validate_each", False)), + branch=getattr(args, "branch", ""), + worktree=getattr(args, "worktree", ""), + limit=int(getattr(args, "limit", 0) or 0), + ) + validation = validate_patch_swarm_run(run_dir) + status = "completed" if validation.get("status") == "passed" and integration.get("status") == "completed" else "blocked" + payload = { + "status": status, + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "candidate_target": manifest.get("candidate_target"), + "candidate_count": receipt.get("candidate_count", 0), + "proreq_execution_count": manifest.get("proreq_execution_count"), + "selected_count": integration.get("selected_count", 0), + "estimated_cost_usd": receipt.get("estimated_cost_usd", 0.0), + "validation": validation.get("status", "unknown"), + "safe_integrator_handoff": integration.get("safe_integrator_handoff", ""), + "ui_state": rel(run_dir / "ui_state.json"), + "decision_report": rel(run_dir / "decision_report.md"), + "planner": { + "run_dir": planner_payload.get("run_dir", ""), + "split_plan": "workspace/runs/parallel-delivery/planner-fixture/split-plan.json", + "task_graph": "workspace/runs/parallel-delivery/planner-fixture/task-graph.json", + "candidate_count": planner_payload.get("candidate_count", 0), + "state": planner_payload.get("state", "unknown"), + }, + } + print(json.dumps(payload, indent=2) if args.json else f"{status} {payload['candidate_count']} candidates {rel(run_dir)}") + return 0 if status == "completed" else 1 + + +def command_self_improve_run(args: argparse.Namespace) -> int: + started = time.perf_counter() + run_dir = resolve_self_run_dir(args.run_dir, create=True) + seed = resolve_self_seed() + manifest = { + "schema_version": SCHEMA_SELF_MANIFEST, + "cycle_id": run_dir.name, + "created_at": now_iso(), + "seed_source": seed.get("source"), + "seed_path": seed.get("path"), + "seed_objective": seed_objective(seed), + "pass_count": 4, + "budget": { + "target_usd": AGENT_PREFERRED_COMPUTE_POLICY["target_spend_usd_max"], + "hard_cap_usd": AGENT_PREFERRED_COMPUTE_POLICY["hard_spend_usd_max"], + }, + "autonomy_mode": "plan_then_gate", + "implementation_execution": "disabled", + "scheduler": { + "trigger": "manual" if not args.scheduler_trigger else args.scheduler_trigger, + "cron_time": args.cron_time, + "command": "cento parallel-delivery self-improve run --json", + }, + "live_policy": { + "live_pro_requested": bool(args.live_pro), + "openai_api_key_present": bool(os.environ.get("OPENAI_API_KEY")), + "image_requested": True, + "image_failure_blocks_backend": False, + }, + "compute_routing_policy": AGENT_PREFERRED_COMPUTE_POLICY, + } + write_self_json(run_dir, "nightly_cycle_manifest.json", manifest) + pass_records: list[dict[str, Any]] = [] + for index, focus in enumerate(SELF_IMPROVE_PASS_FOCUS, start=1): + if not args.quiet: + print(f"self-improve pass {index}/4 {focus['id']}", flush=True) + record = execute_self_improve_pass(index, focus, seed, pass_records, args) + record["cycle_id"] = run_dir.name + pass_records.append(record) + write_self_json(run_dir, f"pass_{index:02d}_child_run_summary.json", record) + if args.sleep_seconds > 0 and index < len(SELF_IMPROVE_PASS_FOCUS): + time.sleep(args.sleep_seconds) + + preliminary_gates = self_improve_validation(run_dir) + recommendation = promotion_recommendation(pass_records, preliminary_gates) + metrics = loop_metrics_payload(run_dir, started, pass_records) + next_request = next_cycle_request_payload(seed, pass_records, preliminary_gates, recommendation) + handoff = evidence_handoff_payload(run_dir, pass_records, preliminary_gates, recommendation) + write_self_json(run_dir, "loop_metrics.json", metrics) + write_self_json(run_dir, "promotion_recommendation.json", recommendation) + write_self_json(run_dir, "evidence_handoff.json", handoff) + write_self_json(run_dir, "next_cycle_request.json", next_request) + gates = self_improve_validation(run_dir) + write_self_json(run_dir, "validation_gates.json", gates) + if gates.get("status") != preliminary_gates.get("status"): + recommendation = promotion_recommendation(pass_records, gates) + handoff = evidence_handoff_payload(run_dir, pass_records, gates, recommendation) + next_request = next_cycle_request_payload(seed, pass_records, gates, recommendation) + write_self_json(run_dir, "promotion_recommendation.json", recommendation) + write_self_json(run_dir, "evidence_handoff.json", handoff) + write_self_json(run_dir, "next_cycle_request.json", next_request) + payload = { + "status": "completed" if gates.get("status") == "passed" else "blocked", + "run_dir": rel(run_dir), + "latest_dir": rel(self_latest_dir()), + "validation": gates.get("status"), + "promotion_recommendation": recommendation.get("recommendation"), + "next_cycle_request": rel(run_dir / "next_cycle_request.json"), + } + print(json.dumps(payload, indent=2) if args.json else f"{payload['status']} {payload['validation']} {payload['run_dir']}") + return 0 if gates.get("status") == "passed" else 1 + + +def command_self_improve_e2e(args: argparse.Namespace) -> int: + run_id = args.run_id or f"self-improve-e2e-{now_stamp()}" + run_dir = resolve_self_e2e_run_dir(run_id, create=True) + fixture_only = bool(getattr(args, "fixture_only", False)) + source = self_e2e_source_payload(run_dir, fixture_only=fixture_only) + request = source.get("request") if isinstance(source.get("request"), dict) else {} + objective = str(request.get("objective") or request.get("seed_objective") or PATCH_SWARM_OBJECTIVE) + patch_run_dir = run_dir / f"patch-swarm-{run_dir.name}" + factory_run_dir = FACTORY_RUNS_ROOT / f"ai-self-improvement-e2e-{run_dir.name}" + manifest = { + "schema_version": SCHEMA_SELF_E2E, + "run_id": run_dir.name, + "created_at": now_iso(), + "status": "running", + "source": rel(run_dir / "self_improve_source.json"), + "candidate_target": int(getattr(args, "candidate_target", 30) or 30), + "max_parallel_agents": int(getattr(args, "max_parallel_agents", 3) or 3), + "budget_cap_usd": float(getattr(args, "budget_cap_usd", 1.0) or 0.0), + "max_budget_usd": float(getattr(args, "max_budget_usd", 1.0) or 0.0), + "fixture_only": fixture_only, + "apply": bool(getattr(args, "apply", False)), + "validate_each": bool(getattr(args, "validate_each", False)), + "auto_merge_gate": bool(getattr(args, "auto_merge_gate", False)), + "no_main_push": True, + "artifacts": { + "self_improve_source": rel(run_dir / "self_improve_source.json"), + "patch_swarm_result": rel(run_dir / "patch_swarm_result.json"), + "factory_promotion": rel(run_dir / "factory_promotion.json"), + "safe_integrator_apply": rel(run_dir / "safe_integrator_apply.json"), + "auto_merge_gate": rel(run_dir / "auto_merge_gate.json"), + "spend_summary": rel(run_dir / "spend_summary.json"), + "validation_summary": rel(run_dir / "validation_summary.json"), + "handoff": rel(run_dir / "handoff.md"), + }, + } + write_json(run_dir / "e2e_manifest.json", manifest) + + patch_manifest: dict[str, Any] = {} + patch_receipt: dict[str, Any] = {} + integration: dict[str, Any] = {} + patch_validation: dict[str, Any] = {} + promotion: dict[str, Any] = {} + safe_apply: dict[str, Any] = {} + auto_gate: dict[str, Any] = {} + status = "blocked" + + if source.get("status") == "ready": + patch_manifest = build_patch_swarm_plan( + patch_run_dir, + objective=objective, + candidate_target=int(getattr(args, "candidate_target", 30) or 30), + max_parallel_agents=int(getattr(args, "max_parallel_agents", 3) or 3), + providers=patch_swarm_provider_list(getattr(args, "providers", "")), + live=not fixture_only, + ) + retarget_patch_swarm_to_sandbox(patch_run_dir, run_dir / "sandbox") + patch_receipt = execute_patch_swarm( + patch_run_dir, + fixture=fixture_only, + budget_cap_usd=getattr(args, "budget_cap_usd", 1.0), + max_budget_usd=getattr(args, "max_budget_usd", 1.0), + api_sandbox_candidates=int(getattr(args, "api_sandbox_candidates", 1) or 0), + api_profile=getattr(args, "api_profile", PATCH_SWARM_API_PROFILE), + api_config=getattr(args, "api_config", str(ROOT / ".cento" / "api_workers.yaml")), + ) + if patch_receipt.get("status") == "candidates_generated": + integration = integrate_patch_swarm( + patch_run_dir, + apply=bool(getattr(args, "apply", False)), + factory_run=rel(factory_run_dir), + validate_each=bool(getattr(args, "validate_each", False)), + branch=getattr(args, "branch", ""), + worktree=getattr(args, "worktree", ""), + limit=int(getattr(args, "limit", 1) or 0), + ) + patch_validation = validate_patch_swarm_run(patch_run_dir) + promotion = read_json(patch_run_dir / "factory_promotion.json") + if promotion: + factory_run_value = str(promotion.get("factory_run_dir") or rel(factory_run_dir)) + factory_run_dir = resolve_cento_path(factory_run_value) + safe_apply = safe_integrator_apply_summary(factory_run_dir, promotion) + if getattr(args, "auto_merge_gate", False): + auto_gate = run_self_e2e_auto_merge_gate(factory_run_dir) + if auto_gate and auto_merge_environment_blocked(auto_gate.get("receipt") if isinstance(auto_gate.get("receipt"), dict) else {}): + status = "auto_merge_blocked_by_environment" + elif safe_apply.get("status") == "applied": + status = "applied" + elif promotion.get("status") == "ready_for_apply": + status = "ready_for_apply" + elif promotion.get("status") == "release_candidate_ready": + status = "applied" + else: + status = "blocked" + + spend = patch_swarm_spend_summary(patch_run_dir) + write_json(run_dir / "patch_swarm_result.json", {"manifest": patch_manifest, "receipt": patch_receipt, "integration": integration, "validation": patch_validation}) + write_json(run_dir / "factory_promotion.json", promotion) + write_json(run_dir / "safe_integrator_apply.json", safe_apply) + write_json(run_dir / "auto_merge_gate.json", auto_gate or {"schema_version": "cento.ai_self_improvement_e2e.auto_merge_gate.v1", "status": "skipped", "dry_run": True, "push_requested": False}) + write_json(run_dir / "spend_summary.json", spend) + validation_summary = self_e2e_validation_summary( + run_dir, + source=source, + patch_receipt=patch_receipt, + integration=integration, + patch_validation=patch_validation, + promotion=promotion, + safe_apply=safe_apply, + auto_gate=auto_gate, + status=status, + ) + write_json(run_dir / "validation_summary.json", validation_summary) + manifest["status"] = status + manifest["updated_at"] = now_iso() + manifest["patch_swarm_run_dir"] = rel(patch_run_dir) + manifest["factory_run_dir"] = rel(factory_run_dir) if promotion else "" + manifest["latest_dir"] = rel(self_e2e_latest_dir()) + write_json(run_dir / "e2e_manifest.json", manifest) + payload = { + "status": status, + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "latest_dir": rel(self_e2e_latest_dir()), + "patch_swarm_run_dir": rel(patch_run_dir), + "factory_run_dir": rel(factory_run_dir) if promotion else "", + "candidate_count": patch_receipt.get("candidate_count", 0), + "selected_count": integration.get("selected_count", 0), + "validation": validation_summary.get("status"), + "spend_summary": rel(run_dir / "spend_summary.json"), + "auto_merge_gate": rel(run_dir / "auto_merge_gate.json"), + "handoff": rel(run_dir / "handoff.md"), + } + write_self_e2e_handoff(run_dir, payload) + mirror_self_e2e_latest(run_dir) + print(json.dumps(payload, indent=2) if args.json else f"{status} {rel(run_dir)}") + return 0 if status in {"ready_for_apply", "applied", "auto_merge_blocked_by_environment"} else 1 + + +def command_self_improve_validate(args: argparse.Namespace) -> int: + run_dir = resolve_self_run_dir(args.run_dir) + gates = self_improve_validation(run_dir) + if run_dir.name == (read_json(self_latest_dir() / "nightly_cycle_manifest.json").get("cycle_id") or ""): + write_self_json(run_dir, "validation_gates.json", gates) + else: + write_json(run_dir / "validation_gates.json", gates) + payload = {"run_dir": rel(run_dir), **gates} + print(json.dumps(payload, indent=2) if args.json else f"{gates['status']} {rel(run_dir)}") + return 0 if gates.get("status") == "passed" else 1 + + +def self_improve_status_payload(run_dir: Path | None, *, crontab_file: str = "") -> dict[str, Any]: + crontab_text = read_crontab(crontab_file) + cron_installed = SELF_CRON_BEGIN in crontab_text and SELF_CRON_END in crontab_text + payload: dict[str, Any] = { + "run_dir": rel(run_dir) if run_dir else "", + "latest_dir": rel(self_latest_dir()), + "cron_installed": cron_installed, + "cron_block": self_cron_block("02:30") if cron_installed else "", + } + if run_dir: + manifest = read_json(run_dir / "nightly_cycle_manifest.json") + gates = read_json(run_dir / "validation_gates.json") + metrics = read_json(run_dir / "loop_metrics.json") + recommendation = read_json(run_dir / "promotion_recommendation.json") + payload.update( + { + "cycle_id": manifest.get("cycle_id", run_dir.name), + "status": "completed" if gates.get("status") == "passed" else ("blocked" if gates else "unknown"), + "validation": gates.get("status", "unknown"), + "degraded_pass_count": metrics.get("degraded_pass_count", 0), + "promotion_recommendation": recommendation.get("recommendation", "unknown"), + "next_cycle_request": rel(run_dir / "next_cycle_request.json"), + } + ) + else: + payload.update({"status": "unknown", "validation": "unknown", "promotion_recommendation": "unknown"}) + return payload + + +def command_self_improve_status(args: argparse.Namespace) -> int: + run_dir = resolve_self_run_dir(args.run_dir) if args.run_dir or self_improve_latest_run_dir() else None + payload = self_improve_status_payload(run_dir, crontab_file=args.crontab_file) + print(json.dumps(payload, indent=2) if args.json else f"{payload['status']} {payload['validation']} {payload['run_dir']}") + return 0 + + +def command_self_improve_install_cron(args: argparse.Namespace) -> int: + try: + block = self_cron_block(args.time) + except ValueError as exc: + print(f"parallel-delivery self-improve install-cron: {exc}", file=sys.stderr) + return 2 + current = read_crontab(args.crontab_file) + updated = strip_self_cron_block(current) + if updated.strip(): + updated = updated.rstrip() + "\n" + updated += block + if not args.dry_run: + write_crontab(updated, args.crontab_file) + payload = { + "status": "installed" if not args.dry_run else "planned", + "time": args.time, + "cron_block": block, + "crontab_file": args.crontab_file, + "dry_run": bool(args.dry_run), + } + print(json.dumps(payload, indent=2) if args.json else payload["status"]) + return 0 + + +def command_self_improve_uninstall_cron(args: argparse.Namespace) -> int: + current = read_crontab(args.crontab_file) + updated = strip_self_cron_block(current) + if not args.dry_run: + write_crontab(updated, args.crontab_file) + payload = { + "status": "uninstalled" if not args.dry_run else "planned", + "cron_installed_before": SELF_CRON_BEGIN in current, + "crontab_file": args.crontab_file, + "dry_run": bool(args.dry_run), + } + print(json.dumps(payload, indent=2) if args.json else payload["status"]) + return 0 + + +def add_self_improve_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + self_cmd = sub.add_parser("self-improve", help="Run and manage the gated nightly four-pass self-improvement loop.") + self_sub = self_cmd.add_subparsers(dest="self_command", required=True) + + run = self_sub.add_parser("run", help="Run the four-pass nightly planning loop and write gated artifacts.") + run.add_argument("--run-dir", default="") + run.add_argument("--sleep-seconds", type=float, default=0.0) + run.add_argument("--poll-seconds", type=float, default=3.0) + run.add_argument("--per-run-timeout", type=int, default=600) + run.add_argument("--step-timeout", type=int, default=240) + run.add_argument("--pro-timeout", type=int, default=240) + run.add_argument("--image-timeout", type=int, default=240) + run.add_argument("--reference-screenshot", default="") + run.add_argument("--live-pro", action="store_true", help="Enable live Pro planning when OPENAI_API_KEY is configured.") + run.add_argument("--plan-only", action="store_true", help="Seed child runs without waiting for full Hard ProReq execution.") + run.add_argument("--scheduler-trigger", default="") + run.add_argument("--cron-time", default="02:30") + run.add_argument("--quiet", action="store_true") + run.add_argument("--json", action="store_true") + run.set_defaults(func=command_self_improve_run) + + e2e = self_sub.add_parser("e2e", help="Run the self-improvement autopilot through Patch Swarm, Factory, Safe Integrator, and dry-run merge gates.") + e2e.add_argument("--run-id", default="") + e2e.add_argument("--candidate-target", type=int, default=30) + e2e.add_argument("--max-parallel-agents", type=int, default=3) + e2e.add_argument("--providers", default=",".join(PATCH_SWARM_PROVIDERS)) + e2e.add_argument("--budget-cap-usd", type=float, default=1.0) + e2e.add_argument("--max-budget-usd", type=float, default=1.0) + e2e.add_argument("--api-sandbox-candidates", type=int, default=1) + e2e.add_argument("--api-profile", default=PATCH_SWARM_API_PROFILE) + e2e.add_argument("--api-config", default=str(ROOT / ".cento" / "api_workers.yaml")) + e2e.add_argument("--fixture-only", action="store_true", help="Use deterministic Patch Swarm fixture candidates and skip live API dispatch.") + e2e.add_argument("--apply", action="store_true", help="Apply at most --limit selected candidate(s) in the Factory integration worktree.") + e2e.add_argument("--validate-each", action="store_true") + e2e.add_argument("--auto-merge-gate", action="store_true", help="Run factory merge --auto-merge-main --dry-run --json without pushing.") + e2e.add_argument("--branch", default="") + e2e.add_argument("--worktree", default="") + e2e.add_argument("--limit", type=int, default=1) + e2e.add_argument("--json", action="store_true") + e2e.set_defaults(func=command_self_improve_e2e) + + validate = self_sub.add_parser("validate", help="Validate the latest or selected nightly self-improvement run.") + validate.add_argument("--run-dir", default="") + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_self_improve_validate) + + status = self_sub.add_parser("status", help="Show latest nightly self-improvement state and cron installation status.") + status.add_argument("--run-dir", default="") + status.add_argument("--crontab-file", default=os.environ.get("CENTO_SELF_IMPROVE_CRONTAB_PATH", "")) + status.add_argument("--json", action="store_true") + status.set_defaults(func=command_self_improve_status) + + install = self_sub.add_parser("install-cron", help="Install the nightly cron block.") + install.add_argument("--time", default="02:30") + install.add_argument("--crontab-file", default=os.environ.get("CENTO_SELF_IMPROVE_CRONTAB_PATH", "")) + install.add_argument("--dry-run", action="store_true") + install.add_argument("--json", action="store_true") + install.set_defaults(func=command_self_improve_install_cron) + + uninstall = self_sub.add_parser("uninstall-cron", help="Remove the nightly cron block.") + uninstall.add_argument("--crontab-file", default=os.environ.get("CENTO_SELF_IMPROVE_CRONTAB_PATH", "")) + uninstall.add_argument("--dry-run", action="store_true") + uninstall.add_argument("--json", action="store_true") + uninstall.set_defaults(func=command_self_improve_uninstall_cron) + + +def add_train_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + train = sub.add_parser("train", help="Plan and run a dry-run parallel integration train from a Workset.") + train_sub = train.add_subparsers(dest="train_command", required=True) + + plan = train_sub.add_parser("plan", help="Create a dry-run train manifest and sequential integration queue.") + plan.add_argument("--workset", required=True) + plan.add_argument("--max-parallel", type=int, default=10) + plan.add_argument("--run-id", default="") + plan.add_argument("--json", action="store_true") + plan.set_defaults(func=command_train_plan) + + run = train_sub.add_parser("run", help="Simulate train workers or execute the copied Workset through the parallel workset pipeline.") + run.add_argument("run_id") + run_mode = run.add_mutually_exclusive_group() + run_mode.add_argument("--simulate", action="store_true") + run_mode.add_argument("--workset-execute", action="store_true", help="Run the copied workset through `cento workset execute` without patch apply.") + run.add_argument("--runtime", choices=["fixture", "local-command", "api-openai"], default="fixture") + run.add_argument("--runtime-profile", default="") + run.add_argument("--api-profile", default="api-section-worker") + run.add_argument("--api-config", default=str(ROOT / ".cento" / "api_workers.yaml")) + run.add_argument("--budget-usd", type=float, default=None, help="Target API budget; required with --runtime api-openai.") + run.add_argument("--max-budget-usd", type=float, default=None, help="Hard API budget cap; required with --runtime api-openai.") + run.add_argument("--validation", default="smoke") + run.add_argument("--worker-timeout", type=int, default=None) + run.add_argument("--retry-attempts", type=int, default=None) + run.add_argument("--fixture-case", default="valid", choices=["valid", "unowned", "protected", "delete", "lockfile", "binary"]) + run.add_argument("--allow-dirty-owned", action="store_true") + run.add_argument("--allow-creates", action="store_true") + run.add_argument("--json", action="store_true") + run.set_defaults(func=command_train_run) + + integrate = train_sub.add_parser("integrate", help="Plan sequential integration in dry-run mode.") + integrate.add_argument("run_id") + integrate.add_argument("--dry-run", action="store_true") + integrate.add_argument("--json", action="store_true") + integrate.set_defaults(func=command_train_integrate) + + status = train_sub.add_parser("status", help="Show train status.") + status.add_argument("run_id", nargs="?", default="") + status.add_argument("--json", action="store_true") + status.set_defaults(func=command_train_status) + + validate = train_sub.add_parser("validate", help="Validate train artifacts.") + validate.add_argument("run_id", nargs="?", default="") + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_train_validate) + + promote = train_sub.add_parser("promote", help="Promote a completed train Workset receipt into a Factory Safe Integrator handoff.") + promote.add_argument("run_id") + promote.add_argument("--dry-run", action="store_true", help="Create Factory handoff and apply plan without applying patches. This is the default.") + promote.add_argument("--apply", action="store_true", help="Apply accepted patches into a Factory integration worktree branch.") + promote.add_argument("--validate-each", action="store_true") + promote.add_argument("--branch", default="") + promote.add_argument("--worktree", default="") + promote.add_argument("--limit", type=int, default=0) + promote.add_argument("--json", action="store_true") + promote.set_defaults(func=command_train_promote) + + e2e = train_sub.add_parser("e2e", help="Plan, execute, validate, and promote a Workset-backed train run.") + e2e.add_argument("--workset", required=True) + e2e.add_argument("--max-parallel", type=int, default=10) + e2e.add_argument("--run-id", default="") + e2e.add_argument("--runtime", choices=["fixture", "local-command", "api-openai"], default="fixture") + e2e.add_argument("--runtime-profile", default="") + e2e.add_argument("--api-profile", default="api-section-worker") + e2e.add_argument("--api-config", default=str(ROOT / ".cento" / "api_workers.yaml")) + e2e.add_argument("--budget-usd", type=float, default=None) + e2e.add_argument("--max-budget-usd", type=float, default=None) + e2e.add_argument("--validation", default="smoke") + e2e.add_argument("--worker-timeout", type=int, default=None) + e2e.add_argument("--retry-attempts", type=int, default=None) + e2e.add_argument("--fixture-case", default="valid", choices=["valid", "unowned", "protected", "delete", "lockfile", "binary"]) + e2e.add_argument("--allow-dirty-owned", action="store_true") + e2e.add_argument("--allow-creates", action="store_true") + e2e.add_argument("--dry-run", action="store_true", help="Create Factory handoff and apply plan without applying patches. This is the default.") + e2e.add_argument("--apply", action="store_true", help="Apply accepted patches into a Factory integration worktree branch.") + e2e.add_argument("--validate-each", action="store_true") + e2e.add_argument("--branch", default="") + e2e.add_argument("--worktree", default="") + e2e.add_argument("--limit", type=int, default=0) + e2e.add_argument("--json", action="store_true") + e2e.set_defaults(func=command_train_e2e) + + +def add_patch_swarm_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + swarm = sub.add_parser("patch-swarm", help="Generate, rank, and integrate many provider-diverse patch candidates.") + swarm_sub = swarm.add_subparsers(dest="patch_swarm_command", required=True) + + plan = swarm_sub.add_parser("plan", help="Create a Patch Swarm manifest with 10 ProReq executions and one integrator.") + plan.add_argument("--run-id", default="") + plan.add_argument("--objective", default=PATCH_SWARM_OBJECTIVE) + plan.add_argument("--candidate-target", type=int, default=100) + plan.add_argument("--max-parallel-agents", type=int, default=5) + plan.add_argument("--providers", default=",".join(PATCH_SWARM_PROVIDERS)) + plan.add_argument("--live", action="store_true", help="Mark the plan live-capable. Fixture execution remains the default.") + plan.add_argument("--json", action="store_true") + plan.set_defaults(func=command_patch_swarm_plan) + + split = swarm_sub.add_parser("split", help="Create Patch Swarm split-plan, task-graph, and task contract artifacts.") + planner_tool.add_split_args(split) + split.set_defaults(func=command_patch_swarm_split) + + leases = swarm_sub.add_parser("leases", help="Create Patch Swarm path leases from split-plan/task-graph artifacts.") + lease_tool.add_create_args(leases) + leases.set_defaults(func=command_patch_swarm_leases) + + validate_leases = swarm_sub.add_parser("validate-leases", help="Validate Patch Swarm path leases without applying patches.") + lease_tool.add_validate_args(validate_leases) + validate_leases.set_defaults(func=command_patch_swarm_validate_leases) + + prompts = swarm_sub.add_parser("prompts", help="Generate local ChatGPT Pro prompt bundles from Patch Swarm run artifacts.") + prompts_tool.add_common_generation_args(prompts) + prompts.set_defaults(func=command_patch_swarm_prompts) + + worker_packets = swarm_sub.add_parser("worker-packets", help="Generate local Codex worker packets from Patch Swarm task leases.") + worker_packets.add_argument("--run-dir", required=True, help="Run directory containing request.md, split-plan.json, task-graph.json, and path-leases.json.") + worker_packets.add_argument("--run-id", default="") + worker_packets.add_argument("--fixture", action="store_true", help="Write deterministic fixture inputs before generating packets.") + worker_packets.add_argument("--count", type=int, default=None) + worker_packets.add_argument("--fixed-timestamp", default="") + worker_packets.add_argument("--json", action="store_true") + worker_packets.set_defaults(func=command_patch_swarm_worker_packets) + + dispatch = swarm_sub.add_parser("dispatch", help="Plan bounded dry-run Patch Swarm worker dispatch without launching agents.") + worker_status_tool.add_dispatch_args(dispatch) + dispatch.set_defaults(func=command_patch_swarm_dispatch) + + worker_status = swarm_sub.add_parser("worker-status", help="Show Patch Swarm worker-pool and process visibility status.") + worker_status_tool.add_status_args(worker_status) + worker_status.set_defaults(func=command_patch_swarm_worker_status) + + execute = swarm_sub.add_parser("execute", help="Generate normalized candidate_patch.v1 receipts.") + execute.add_argument("run_id", nargs="?", default="") + execute.add_argument("--fixture", action="store_true", help="Use deterministic fixture candidates. This is the default.") + execute.add_argument("--live", action="store_true", help="Use a live-enabled plan after budget and adapter gates pass.") + execute.add_argument("--budget-cap-usd", "--budget-cap", dest="budget_cap_usd", type=float, default=None, help="Required live provider spend cap.") + execute.add_argument("--max-budget-usd", type=float, default=None, help="Optional hard live cap ceiling. Defaults to the rollout ceiling.") + execute.add_argument("--api-sandbox-candidates", type=int, default=1, help="Maximum metered api-openai patch_proposal.v1 candidates to dispatch.") + execute.add_argument("--api-profile", default=PATCH_SWARM_API_PROFILE) + execute.add_argument("--api-config", default=str(ROOT / ".cento" / "api_workers.yaml")) + execute.add_argument("--json", action="store_true") + execute.set_defaults(func=command_patch_swarm_execute) + + integrate = swarm_sub.add_parser("integrate", help="Run the dedicated serialized integration execution.") + integrate.add_argument("run_id", nargs="?", default="") + integrate.add_argument("--dry-run", action="store_true", help="Write Safe Integrator handoff without applying patches. This is the default.") + integrate.add_argument("--apply", action="store_true", help="Apply selected candidates in a Factory/Safe Integrator worktree.") + integrate.add_argument("--factory-run", default="", help="Factory run directory. Defaults to workspace/runs/factory/patch-swarm-RUN.") + integrate.add_argument("--validate-each", action="store_true") + integrate.add_argument("--branch", default="") + integrate.add_argument("--worktree", default="") + integrate.add_argument("--limit", type=int, default=0) + integrate.add_argument("--json", action="store_true") + integrate.set_defaults(func=command_patch_swarm_integrate) + + validate = swarm_sub.add_parser("validate", help="Validate Patch Swarm artifacts, candidate counts, providers, and integration handoff.") + validate.add_argument("run_id", nargs="?", default="") + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_patch_swarm_validate) + + status = swarm_sub.add_parser("status", help="Show latest or selected Patch Swarm status.") + status.add_argument("run_id", nargs="?", default="") + status.add_argument("--run-dir", default="", help="Read console status from an explicit Patch Swarm run directory.") + status.add_argument("--output-dir", default="", help="Write console export files here. Defaults to --run-dir.") + status.add_argument("--write-html", action="store_true", help="Write start-here.html next to console-data.json.") + status.add_argument("--strict-links", action="store_true", help="Fail if generated console links are missing or escape the run directory.") + status.add_argument("--json", action="store_true") + status.set_defaults(func=command_patch_swarm_status) + + e2e = swarm_sub.add_parser("e2e", help="Plan, generate 100+ candidates, integrate winners, and validate.") + e2e.add_argument("--run-id", default="") + e2e.add_argument("--run-root", default=str(validation_e2e_tool.DEFAULT_RUN_ROOT), help="Fixture E2E run root.") + e2e.add_argument("--output-dir", default="", help="Exact fixture run directory to write. Overrides --run-root when provided.") + e2e.add_argument("--objective", default=PATCH_SWARM_OBJECTIVE) + e2e.add_argument("--candidate-target", type=int, default=100) + e2e.add_argument("--max-parallel-agents", type=int, default=5) + e2e.add_argument("--providers", default=",".join(PATCH_SWARM_PROVIDERS)) + e2e.add_argument("--fixture", action="store_true", help="Use deterministic fixture candidates. This is the default.") + e2e.add_argument("--live", action="store_true", help="Use a live-enabled plan after budget and adapter gates pass.") + e2e.add_argument("--budget-cap-usd", "--budget-cap", dest="budget_cap_usd", type=float, default=None) + e2e.add_argument("--max-budget-usd", type=float, default=None) + e2e.add_argument("--api-sandbox-candidates", type=int, default=1) + e2e.add_argument("--api-profile", default=PATCH_SWARM_API_PROFILE) + e2e.add_argument("--api-config", default=str(ROOT / ".cento" / "api_workers.yaml")) + e2e.add_argument("--apply", action="store_true", help="Apply selected candidates in a Factory/Safe Integrator worktree.") + e2e.add_argument("--factory-run", default="") + e2e.add_argument("--validate-each", action="store_true") + e2e.add_argument("--branch", default="") + e2e.add_argument("--worktree", default="") + e2e.add_argument("--limit", type=int, default=0) + e2e.add_argument("--dry-run", action=argparse.BooleanOptionalAction, default=True, help="Write dry-run integration receipts without applying patches. This is the fixture default.") + e2e.add_argument("--fixed-timestamp", default="", help="Use a deterministic timestamp for fixture E2E artifacts.") + e2e.add_argument("--include-unsafe-fixture", action=argparse.BooleanOptionalAction, default=True, help="Include an unsafe out-of-lease bundle and prove it is rejected.") + e2e.add_argument("--json", action="store_true") + e2e.set_defaults(func=command_patch_swarm_e2e) + + +def add_patch_bundles_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + bundles = sub.add_parser("patch-bundles", help="Collect and validate local Patch Swarm patch bundles.") + bundle_sub = bundles.add_subparsers(dest="patch_bundle_command", required=True) + + validate = bundle_sub.add_parser("validate", help="Validate one local Patch Swarm bundle without applying it.") + validate.add_argument("--bundle", required=True) + validate.add_argument("--lease-manifest", required=True) + validate.add_argument("--out", required=True) + validate.add_argument("--run-id", default="") + validate.add_argument("--base-commit", default="") + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_patch_bundles_validate) + + collect = bundle_sub.add_parser("collect", help="Collect and validate a directory of local Patch Swarm bundles.") + collect.add_argument("--bundles-dir", required=True) + collect.add_argument("--lease-manifest", required=True) + collect.add_argument("--out", required=True) + collect.add_argument("--run-id", required=True) + collect.add_argument("--base-commit", default="") + collect.add_argument("--json", action="store_true") + collect.set_defaults(func=command_patch_bundles_collect) + + +def add_release_candidate_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + release = sub.add_parser("release-candidate", help="Create safe apply receipts and release-candidate evidence from accepted integration receipts.") + release_sub = release.add_subparsers(dest="release_candidate_command", required=True) + + create = release_sub.add_parser("create", help="Dry-run or apply accepted patch bundles in an isolated target and write release-candidate evidence.") + release_candidate_tool.add_create_args(create) + create.set_defaults(func=command_release_candidate_create) + + +def add_taskstream_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + taskstream = sub.add_parser("taskstream", help="Emit Patch Swarm task handoff manifests for cento agent-work.") + taskstream_sub = taskstream.add_subparsers(dest="taskstream_command", required=True) + + emit = taskstream_sub.add_parser("emit", help="Generate local story/validation manifests from a Patch Swarm split plan.") + emit.add_argument("--split-plan", required=True) + emit.add_argument("--out", required=True) + emit.add_argument("--transport", choices=["auto", "mcp", "agent-work", "manifest-only"], default="manifest-only") + emit.add_argument("--run-preflight", action=argparse.BooleanOptionalAction, default=True) + emit.add_argument("--default-route", choices=["agent-work", "manifest-only"], default="agent-work") + emit.add_argument("--json", action="store_true") + emit.set_defaults(func=command_taskstream_emit) + + preflight = taskstream_sub.add_parser("preflight", help="Validate generated work packages and run safe agent-work preflight.") + preflight.add_argument("--manifest-dir", required=True) + preflight.add_argument("--out", required=True) + preflight.add_argument("--json", action="store_true") + preflight.set_defaults(func=command_taskstream_preflight) + + apply_parser = taskstream_sub.add_parser("apply", help="Submit generated work packages through approved Taskstream surfaces.") + apply_parser.add_argument("--manifest-dir", required=True) + apply_parser.add_argument("--out", required=True) + apply_parser.add_argument("--transport", choices=["auto", "mcp", "agent-work"], default="auto") + apply_parser.add_argument("--apply", action="store_true", help="Required for live task creation.") + apply_parser.add_argument("--json", action="store_true") + apply_parser.set_defaults(func=command_taskstream_apply) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Coordinate ProReq, Workset, integration, validation, and demo for parallel AI delivery.") + sub = parser.add_subparsers(dest="command", required=True) + + plan = sub.add_parser("plan", help="Write the VP-level implementation manifest and demo workset.") + plan.add_argument("--run-dir", default="") + plan.add_argument("--json", action="store_true") + plan.set_defaults(func=command_plan) + + execute = sub.add_parser("execute", help="Run Hard ProReq passes, compose manifests, run demo, and validate.") + execute.add_argument("--run-dir", default="") + execute.add_argument("--only", default="", help="Comma-separated workstream ids to run.") + execute.add_argument("--max-passes", type=int, default=0, help="Limit passes for smoke testing.") + execute.add_argument("--sleep-seconds", type=float, default=1.0) + execute.add_argument("--poll-seconds", type=float, default=3.0) + execute.add_argument("--per-run-timeout", type=int, default=600) + execute.add_argument("--step-timeout", type=int, default=240) + execute.add_argument("--pro-timeout", type=int, default=240) + execute.add_argument("--image-timeout", type=int, default=240) + execute.add_argument("--reference-screenshot", default="") + execute.add_argument("--live-pro", action="store_true", help="Enable live Pro dispatch when OPENAI_API_KEY is configured.") + execute.add_argument("--skip-demo", action="store_true") + execute.add_argument("--json", action="store_true") + execute.set_defaults(func=command_execute) + + demo = sub.add_parser("demo", help="Create and optionally execute the 10-lane fixture demo.") + demo.add_argument("--run-dir", default="") + demo.add_argument("--plan-only", action="store_true") + demo.add_argument("--json", action="store_true") + demo.set_defaults(func=command_demo) + + validate = sub.add_parser("validate", help="Validate a parallel delivery run.") + validate.add_argument("--run-dir", default="") + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_validate) + + status = sub.add_parser("status", help="Summarize the latest or selected run.") + status.add_argument("--run-dir", default="") + status.add_argument("--run", default="", help="Run id under --run-root.") + status.add_argument("--run-root", default=str(RUNS_ROOT), help="Run root for --run lookup.") + status.add_argument("--json", action="store_true") + status.set_defaults(func=command_status) + add_train_parser(sub) + add_patch_bundles_parser(sub) + add_release_candidate_parser(sub) + add_taskstream_parser(sub) + add_patch_swarm_parser(sub) + add_self_improve_parser(sub) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery/patch_bundle_fixture.py b/scripts/parallel_delivery/patch_bundle_fixture.py new file mode 100644 index 0000000..9a0ec12 --- /dev/null +++ b/scripts/parallel_delivery/patch_bundle_fixture.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Write deterministic Patch Swarm patch bundle fixture inputs.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import parallel_delivery_patch_bundles as patch_bundles # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Write Patch Swarm patch bundle fixture inputs.") + parser.add_argument("--out", required=True, help="Fixture run directory.") + parser.add_argument("--base-commit", required=True, help="Base commit to record in fixture manifests.") + parser.add_argument("--run-id", default="patch-bundle-fixture") + args = parser.parse_args(argv) + + payload = patch_bundles.build_fixture_inputs(Path(args.out), base_commit=args.base_commit, run_id=args.run_id) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery/release_candidate_fixture.py b/scripts/parallel_delivery/release_candidate_fixture.py new file mode 100644 index 0000000..691b055 --- /dev/null +++ b/scripts/parallel_delivery/release_candidate_fixture.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Write the Parallel Delivery release-candidate fixture inputs.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import parallel_delivery_release_candidate as rc # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Create deterministic safe-apply release-candidate fixture inputs.") + parser.add_argument("--out", required=True) + parser.add_argument("--base-commit", required=True) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + payload = rc.build_release_candidate_fixture(Path(args.out), base_commit=rc.resolve_expected_base_commit(args.base_commit) or args.base_commit) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(payload["run_dir"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery/taskstream_fixture.py b/scripts/parallel_delivery/taskstream_fixture.py new file mode 100755 index 0000000..32bacd6 --- /dev/null +++ b/scripts/parallel_delivery/taskstream_fixture.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Write deterministic Patch Swarm Taskstream handoff fixture input.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import parallel_delivery_taskstream as taskstream # noqa: E402 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create a deterministic Patch Swarm taskstream handoff fixture split plan.") + parser.add_argument("--out", required=True, help="Fixture run directory.") + parser.add_argument("--base-commit", required=True, help="Base commit to record in split-plan.json.") + parser.add_argument("--timestamp", default=taskstream.DEFAULT_TIMESTAMP) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + out_dir = taskstream.resolve_root_path(args.out) + split_plan = taskstream.build_fixture_split_plan(out_dir, base_commit=args.base_commit, timestamp=args.timestamp) + print(f"split_plan: {taskstream.rel(split_plan)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_artifacts.py b/scripts/parallel_delivery_artifacts.py new file mode 100644 index 0000000..2db44dd --- /dev/null +++ b/scripts/parallel_delivery_artifacts.py @@ -0,0 +1,1561 @@ +#!/usr/bin/env python3 +"""Patch Swarm / Parallel Delivery artifact schema helper. + +This module defines the durable artifact contract for Patch Swarm runs. It is a +schema and fixture helper only; it does not plan work, dispatch workers, apply +patches, or mutate Taskstream state. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +CURRENT_SCHEMA_VERSION = 1 +PRODUCER = "cento.parallel-delivery.artifacts" + +ARTIFACT_TYPES = { + "run": "run.json", + "request": "request.md", + "context-pack": "context-pack.json", + "split-plan": "split-plan.json", + "task-graph": "task-graph.json", + "path-leases": "path-leases.json", + "worker-prompts-manifest": "worker-prompts/manifest.json", + "worker-ledger": "worker-ledger.jsonl", + "patch-bundles-manifest": "patch-bundles/manifest.json", + "integration-plan": "integration-plan.json", + "integration-receipt": "integration-receipt.json", + "validation": "validation.json", + "validation-report": "validation-report.md", + "release-candidate": "release-candidate.json", + "release-notes": "release-notes.md", + "start-here": "start-here.md", +} + +RUN_STATES = [ + "request_received", + "run_created", + "context_packed", + "split_planned", + "task_graph_ready", + "paths_leased", + "prompts_emitted", + "workers_started", + "patches_collected", + "validation_started", + "validation_passed", + "validation_failed", + "integration_planned", + "integration_started", + "integration_completed", + "rc_built", + "rc_validated", + "completed", + "failed", + "aborted", +] + +TASK_STATES = [ + "created", + "context_ready", + "leased", + "prompt_emitted", + "dispatched", + "patch_submitted", + "validation_running", + "validation_passed", + "validation_failed", + "queued_for_integration", + "integrated", + "rejected", + "superseded", + "aborted", +] + +LEASE_STATES = ["proposed", "active", "released", "conflict", "expired"] + +RUN_STATE_TRANSITIONS = { + "request_received": ["run_created", "failed", "aborted"], + "run_created": ["context_packed", "split_planned", "failed", "aborted"], + "context_packed": ["split_planned", "failed", "aborted"], + "split_planned": ["task_graph_ready", "paths_leased", "failed", "aborted"], + "task_graph_ready": ["paths_leased", "failed", "aborted"], + "paths_leased": ["prompts_emitted", "failed", "aborted"], + "prompts_emitted": ["workers_started", "patches_collected", "failed", "aborted"], + "workers_started": ["patches_collected", "validation_started", "failed", "aborted"], + "patches_collected": ["validation_started", "failed", "aborted"], + "validation_started": ["validation_passed", "validation_failed", "failed", "aborted"], + "validation_failed": ["integration_planned", "failed", "aborted"], + "validation_passed": ["integration_planned", "failed", "aborted"], + "integration_planned": ["integration_started", "failed", "aborted"], + "integration_started": ["integration_completed", "failed", "aborted"], + "integration_completed": ["rc_built", "failed", "aborted"], + "rc_built": ["rc_validated", "failed", "aborted"], + "rc_validated": ["completed", "failed", "aborted"], + "completed": [], + "failed": [], + "aborted": [], +} + +TASK_STATE_TRANSITIONS = { + "created": ["context_ready", "leased", "aborted", "superseded"], + "context_ready": ["leased", "aborted", "superseded"], + "leased": ["prompt_emitted", "aborted", "superseded"], + "prompt_emitted": ["dispatched", "patch_submitted", "aborted", "superseded"], + "dispatched": ["patch_submitted", "validation_running", "aborted"], + "patch_submitted": ["validation_running", "validation_failed", "aborted"], + "validation_running": ["validation_passed", "validation_failed", "rejected", "aborted"], + "validation_passed": ["queued_for_integration", "rejected", "aborted"], + "validation_failed": ["rejected", "superseded", "aborted"], + "queued_for_integration": ["integrated", "rejected", "aborted"], + "integrated": [], + "rejected": [], + "superseded": [], + "aborted": [], +} + +EDGE_TYPES = ["depends_on", "blocks", "shares_context", "conflicts_with"] +WORKER_LEDGER_EVENT_TYPES = [ + "prompt_emitted", + "dispatch_dry_run", + "worker_started", + "patch_submitted", + "validation_started", + "validation_completed", + "integration_queued", + "integrated", + "rejected", + "operator_note", +] +INTEGRATION_STRATEGIES = ["sequential", "dependency-order"] +INTEGRATION_FINAL_STATES = ["integration_completed", "integration_failed", "integration_aborted"] +VALIDATION_OVERALL_STATES = ["passed", "failed", "partial"] +RELEASE_CANDIDATE_STATES = ["rc_built", "rc_validated", "rc_failed"] +MARKDOWN_PREFIX = "" + + +def _jsonl_dumps(payload: dict[str, Any]) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n" + + +def _require_fields(payload: dict[str, Any], fields: list[str], artifact: str) -> list[str]: + return [f"{artifact}: missing required field {field}" for field in fields if field not in payload] + + +def _validate_iso_z(value: Any, field: str) -> list[str]: + if not isinstance(value, str) or not ISO_Z_RE.match(value): + return [f"{field} must be ISO-8601 UTC with trailing Z"] + return [] + + +def _validate_provenance(value: Any, artifact: str) -> list[str]: + errors: list[str] = [] + if not isinstance(value, dict): + return [f"{artifact}.provenance must be an object"] + for field in ["producer", "command", "source", "repo", "notes"]: + if field not in value: + errors.append(f"{artifact}.provenance missing {field}") + for field in ["producer", "command", "source", "repo"]: + if field in value and not isinstance(value[field], str): + errors.append(f"{artifact}.provenance.{field} must be a string") + if "notes" in value and not isinstance(value["notes"], list): + errors.append(f"{artifact}.provenance.notes must be a list") + return errors + + +def _validate_evidence_pointers(value: Any, artifact: str) -> list[str]: + errors: list[str] = [] + if not isinstance(value, list): + return [f"{artifact}.evidence_pointers must be a list"] + for index, pointer in enumerate(value, start=1): + label = f"{artifact}.evidence_pointers[{index}]" + if not isinstance(pointer, dict): + errors.append(f"{label} must be an object") + continue + if "path" in pointer: + errors.extend(f"{label}.path: {error}" for error in validate_relative_artifact_path(str(pointer["path"]))) + if "sha256" in pointer and not re.fullmatch(r"[0-9a-f]{64}", str(pointer["sha256"])): + errors.append(f"{label}.sha256 must be lowercase hex sha256") + return errors + + +def validate_schema_version(payload: dict, *, allow_future: bool = False) -> list[str]: + """Validate schema_version compatibility.""" + if "schema_version" not in payload: + return ["schema_version missing"] + version = payload.get("schema_version") + if not isinstance(version, int): + return ["schema_version must be an integer"] + if version < CURRENT_SCHEMA_VERSION: + return [f"schema_version {version} is older than supported version {CURRENT_SCHEMA_VERSION}"] + if version > CURRENT_SCHEMA_VERSION and not allow_future: + return [f"schema_version {version} is newer than supported version {CURRENT_SCHEMA_VERSION}"] + return [] + + +def validate_common_json_artifact(payload: dict, artifact_type: str) -> list[str]: + """Validate common JSON fields.""" + errors = _require_fields(payload, COMMON_JSON_FIELDS, artifact_type) + errors.extend(validate_schema_version(payload)) + if payload.get("artifact_type") != artifact_type: + errors.append(f"artifact_type must be {artifact_type}") + if not isinstance(payload.get("run_id"), str) or not payload.get("run_id"): + errors.append("run_id must be a non-empty string") + if "created_at" in payload: + errors.extend(_validate_iso_z(payload["created_at"], "created_at")) + if "updated_at" in payload: + errors.extend(_validate_iso_z(payload["updated_at"], "updated_at")) + if "provenance" in payload: + errors.extend(_validate_provenance(payload["provenance"], artifact_type)) + if "evidence_pointers" in payload: + errors.extend(_validate_evidence_pointers(payload["evidence_pointers"], artifact_type)) + return errors + + +def validate_relative_artifact_path(value: str) -> list[str]: + """Reject absolute paths, '..', .env.mcp, and suspicious secret paths.""" + errors: list[str] = [] + if not value: + return ["path must be non-empty"] + path = Path(value) + lowered = value.lower() + parts = [part.lower() for part in path.parts] + if path.is_absolute() or value.startswith("~"): + errors.append(f"{value}: absolute or home-relative paths are not allowed") + if ".." in path.parts: + errors.append(f"{value}: parent traversal is not allowed") + if ".env.mcp" in parts or lowered.endswith("/.env.mcp"): + errors.append(f"{value}: .env.mcp is not allowed") + secret_markers = [ + ".env", + ".ssh", + "secret", + "secrets", + "token", + "credential", + "credentials", + "openai_api_key", + "api_key", + "private_key", + "id_rsa", + ] + if any(marker in lowered for marker in secret_markers): + errors.append(f"{value}: secret-like artifact paths are not allowed") + return errors + + +def _validate_path_list(value: Any, field: str) -> list[str]: + errors: list[str] = [] + if not isinstance(value, list): + return [f"{field} must be a list"] + for index, item in enumerate(value, start=1): + if not isinstance(item, str): + errors.append(f"{field}[{index}] must be a string") + continue + errors.extend(f"{field}[{index}]: {error}" for error in validate_relative_artifact_path(item)) + return errors + + +def validate_markdown_artifact(path: Path, artifact_type: str, run_id: str) -> list[str]: + """Validate markdown/html metadata comment and required body.""" + errors: list[str] = [] + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return [f"{path}: missing markdown artifact"] + if not text.strip(): + return [f"{path}: markdown artifact is empty"] + first_line = text.splitlines()[0] if text.splitlines() else "" + if not first_line.startswith(MARKDOWN_PREFIX) or not first_line.endswith("-->"): + return [f"{path}: first line must be a cento-artifact metadata comment"] + raw = first_line.removeprefix(MARKDOWN_PREFIX).removesuffix("-->").strip() + try: + metadata = json.loads(raw) + except json.JSONDecodeError as exc: + return [f"{path}: metadata JSON is invalid: {exc.msg}"] + errors.extend(validate_schema_version(metadata)) + if metadata.get("artifact_type") != artifact_type: + errors.append(f"{path}: artifact_type must be {artifact_type}") + if metadata.get("run_id") != run_id: + errors.append(f"{path}: run_id must be {run_id}") + errors.extend(_validate_iso_z(metadata.get("created_at"), f"{path}: created_at")) + body = "\n".join(text.splitlines()[1:]).strip() + if not body: + errors.append(f"{path}: body must be non-empty") + for section in REQUIRED_MD_SECTIONS.get(artifact_type, []): + if section not in text: + errors.append(f"{path}: missing required section {section}") + return errors + + +def validate_run_state_transition(old: str, new: str) -> None: + """Validate run state transition.""" + if old not in RUN_STATE_TRANSITIONS: + raise ArtifactValidationError(f"invalid run state: {old}") + if new not in RUN_STATES: + raise ArtifactValidationError(f"invalid run state: {new}") + if new not in RUN_STATE_TRANSITIONS[old]: + raise ArtifactValidationError(f"invalid run state transition: {old} -> {new}") + + +def validate_task_state_transition(old: str, new: str) -> None: + """Validate task state transition.""" + if old not in TASK_STATE_TRANSITIONS: + raise ArtifactValidationError(f"invalid task state: {old}") + if new not in TASK_STATES: + raise ArtifactValidationError(f"invalid task state: {new}") + if new not in TASK_STATE_TRANSITIONS[old]: + raise ArtifactValidationError(f"invalid task state transition: {old} -> {new}") + + +def validate_run_artifact(payload: dict) -> list[str]: + """Validate run.json.""" + errors = validate_common_json_artifact(payload, "run") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["run"], "run")) + if payload.get("state") not in RUN_STATES: + errors.append("run.state must be a known run state") + if not isinstance(payload.get("request_title"), str) or not payload.get("request_title"): + errors.append("run.request_title must be a non-empty string") + if not isinstance(payload.get("artifact_paths"), dict): + errors.append("run.artifact_paths must be an object") + else: + for key, value in payload["artifact_paths"].items(): + errors.extend(f"run.artifact_paths.{key}: {error}" for error in validate_relative_artifact_path(str(value))) + if not isinstance(payload.get("counts"), dict): + errors.append("run.counts must be an object") + return errors + + +def validate_context_pack(payload: dict) -> list[str]: + """Validate context-pack.json.""" + errors = validate_common_json_artifact(payload, "context-pack") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["context-pack"], "context-pack")) + if "request_ref" in payload: + errors.extend(f"context-pack.request_ref: {error}" for error in validate_relative_artifact_path(str(payload["request_ref"]))) + repo_context = payload.get("repo_context") + if not isinstance(repo_context, dict): + errors.append("context-pack.repo_context must be an object") + else: + for required in ["repo_name", "relevant_surfaces", "dirty_work_policy"]: + if required not in repo_context: + errors.append(f"context-pack.repo_context missing {required}") + source_refs = payload.get("source_refs") + if not isinstance(source_refs, list): + errors.append("context-pack.source_refs must be a list") + else: + for index, ref in enumerate(source_refs, start=1): + if isinstance(ref, dict) and "path" in ref: + errors.extend(f"context-pack.source_refs[{index}].path: {error}" for error in validate_relative_artifact_path(str(ref["path"]))) + text = json.dumps(payload, sort_keys=True).lower() + for forbidden in [".env.mcp", "openai_api_key", "api key", "secret value"]: + if forbidden in text: + errors.append(f"context-pack must not include {forbidden}") + return errors + + +def validate_split_plan(payload: dict) -> list[str]: + """Validate split-plan.json.""" + errors = validate_common_json_artifact(payload, "split-plan") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["split-plan"], "split-plan")) + max_tasks = payload.get("max_candidate_tasks") + if not isinstance(max_tasks, int) or not 1 <= max_tasks <= 100: + errors.append("split-plan.max_candidate_tasks must be between 1 and 100") + tasks = payload.get("tasks") + if not isinstance(tasks, list) or not tasks: + errors.append("split-plan.tasks must be a non-empty list") + return errors + if isinstance(max_tasks, int) and len(tasks) > max_tasks: + errors.append("split-plan.tasks exceeds max_candidate_tasks") + seen: set[str] = set() + for index, task in enumerate(tasks, start=1): + label = f"split-plan.tasks[{index}]" + if not isinstance(task, dict): + errors.append(f"{label} must be an object") + continue + for field in [ + "task_id", + "title", + "summary", + "state", + "acceptance_contract", + "validation_commands", + "owned_paths", + "read_only_paths", + ]: + if field not in task: + errors.append(f"{label} missing {field}") + task_id = task.get("task_id") + if not isinstance(task_id, str) or not task_id: + errors.append(f"{label}.task_id must be a non-empty string") + elif task_id in seen: + errors.append(f"{label}.task_id duplicates {task_id}") + else: + seen.add(task_id) + if task.get("state") not in TASK_STATES: + errors.append(f"{label}.state must be a known task state") + for field in ["acceptance_contract", "validation_commands"]: + if field in task and not isinstance(task[field], list): + errors.append(f"{label}.{field} must be a list") + errors.extend(_validate_path_list(task.get("owned_paths"), f"{label}.owned_paths")) + errors.extend(_validate_path_list(task.get("read_only_paths"), f"{label}.read_only_paths")) + return errors + + +def _has_depends_on_cycle(nodes: set[str], edges: list[dict[str, Any]]) -> bool: + graph = {node: [] for node in nodes} + for edge in edges: + if edge.get("type") == "depends_on": + graph.setdefault(str(edge.get("from")), []).append(str(edge.get("to"))) + visiting: set[str] = set() + visited: set[str] = set() + + def visit(node: str) -> bool: + if node in visiting: + return True + if node in visited: + return False + visiting.add(node) + for child in graph.get(node, []): + if visit(child): + return True + visiting.remove(node) + visited.add(node) + return False + + return any(visit(node) for node in sorted(nodes)) + + +def validate_task_graph(payload: dict) -> list[str]: + """Validate task-graph.json.""" + errors = validate_common_json_artifact(payload, "task-graph") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["task-graph"], "task-graph")) + nodes_value = payload.get("nodes") + edges_value = payload.get("edges") + nodes: set[str] = set() + if not isinstance(nodes_value, list) or not nodes_value: + errors.append("task-graph.nodes must be a non-empty list") + else: + for index, node in enumerate(nodes_value, start=1): + if not isinstance(node, dict) or not isinstance(node.get("task_id"), str) or not node.get("task_id"): + errors.append(f"task-graph.nodes[{index}] must include task_id") + else: + nodes.add(node["task_id"]) + if not isinstance(edges_value, list): + errors.append("task-graph.edges must be a list") + return errors + for index, edge in enumerate(edges_value, start=1): + label = f"task-graph.edges[{index}]" + if not isinstance(edge, dict): + errors.append(f"{label} must be an object") + continue + for field in ["from", "to", "type"]: + if field not in edge: + errors.append(f"{label} missing {field}") + if edge.get("type") not in EDGE_TYPES: + errors.append(f"{label}.type must be one of {', '.join(EDGE_TYPES)}") + for field in ["from", "to"]: + if field in edge and edge[field] not in nodes: + errors.append(f"{label}.{field} references unknown task {edge[field]}") + if _has_depends_on_cycle(nodes, [edge for edge in edges_value if isinstance(edge, dict)]): + errors.append("task-graph depends_on edges must be acyclic") + return errors + + +def validate_path_leases(payload: dict) -> list[str]: + """Validate path-leases.json.""" + errors = validate_common_json_artifact(payload, "path-leases") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["path-leases"], "path-leases")) + leases = payload.get("leases") + if not isinstance(leases, list): + return errors + ["path-leases.leases must be a list"] + active_owners: dict[str, str] = {} + for index, lease in enumerate(leases, start=1): + label = f"path-leases.leases[{index}]" + if not isinstance(lease, dict): + errors.append(f"{label} must be an object") + continue + for field in ["lease_id", "task_id", "state", "owned_paths", "read_only_paths", "created_at"]: + if field not in lease: + errors.append(f"{label} missing {field}") + if lease.get("state") not in LEASE_STATES: + errors.append(f"{label}.state must be a known lease state") + errors.extend(_validate_iso_z(lease.get("created_at"), f"{label}.created_at")) + if lease.get("expires_at"): + errors.extend(_validate_iso_z(lease.get("expires_at"), f"{label}.expires_at")) + errors.extend(_validate_path_list(lease.get("owned_paths"), f"{label}.owned_paths")) + errors.extend(_validate_path_list(lease.get("read_only_paths"), f"{label}.read_only_paths")) + if lease.get("state") == "active" and isinstance(lease.get("owned_paths"), list): + for owned_path in lease["owned_paths"]: + if owned_path in active_owners: + errors.append( + f"path-leases active overlap: {owned_path} owned by {active_owners[owned_path]} and {lease.get('lease_id')}" + ) + else: + active_owners[owned_path] = str(lease.get("lease_id")) + if not isinstance(payload.get("conflicts"), list): + errors.append("path-leases.conflicts must be a list") + return errors + + +def _load_leased_paths(run_dir: Path) -> dict[str, set[str]]: + try: + leases_payload = read_json_artifact(run_dir / "path-leases.json") + except (FileNotFoundError, json.JSONDecodeError, ArtifactValidationError): + return {} + leased: dict[str, set[str]] = {} + for lease in leases_payload.get("leases", []): + if isinstance(lease, dict) and lease.get("state") in {"active", "released"}: + leased.setdefault(str(lease.get("task_id")), set()).update(str(path) for path in lease.get("owned_paths", [])) + return leased + + +def validate_worker_prompts(run_dir: Path) -> list[str]: + """Validate worker-prompts directory and manifest.""" + errors: list[str] = [] + manifest_path = run_dir / ARTIFACT_TYPES["worker-prompts-manifest"] + try: + manifest = read_json_artifact(manifest_path) + except FileNotFoundError: + return [f"{manifest_path}: missing worker prompts manifest"] + except (json.JSONDecodeError, ArtifactValidationError) as exc: + return [f"{manifest_path}: {exc}"] + errors.extend(validate_common_json_artifact(manifest, "worker-prompts-manifest")) + errors.extend(_require_fields(manifest, REQUIRED_JSON_FIELDS["worker-prompts-manifest"], "worker-prompts-manifest")) + prompts = manifest.get("prompts") + if not isinstance(prompts, list) or not prompts: + return errors + ["worker-prompts-manifest.prompts must be a non-empty list"] + for index, item in enumerate(prompts, start=1): + label = f"worker-prompts-manifest.prompts[{index}]" + if not isinstance(item, dict): + errors.append(f"{label} must be an object") + continue + for field in ["task_id", "path", "sha256", "created_at"]: + if field not in item: + errors.append(f"{label} missing {field}") + path_value = str(item.get("path", "")) + errors.extend(f"{label}.path: {error}" for error in validate_relative_artifact_path(path_value)) + if not path_value.startswith("worker-prompts/"): + errors.append(f"{label}.path must live under worker-prompts/") + prompt_path = run_dir / path_value + if prompt_path.exists(): + digest = sha256_file(prompt_path) + if item.get("sha256") != digest: + errors.append(f"{label}.sha256 does not match {path_value}") + errors.extend(validate_markdown_artifact(prompt_path, "worker-prompt", str(manifest.get("run_id")))) + else: + errors.append(f"{label}.path missing file {path_value}") + errors.extend(_validate_iso_z(item.get("created_at"), f"{label}.created_at")) + return errors + + +def validate_worker_ledger(path: Path) -> list[str]: + """Validate worker-ledger.jsonl with line-numbered failures.""" + errors: list[str] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return [f"{path}: missing worker ledger"] + for line_number, line in enumerate(lines, start=1): + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + errors.append(f"{path}: line {line_number}: invalid JSON: {exc.msg}") + continue + if not isinstance(event, dict): + errors.append(f"{path}: line {line_number}: event must be an object") + continue + for field in ["schema_version", "artifact_type", "event_id", "run_id", "event_type", "created_at", "actor", "provenance", "details"]: + if field not in event: + errors.append(f"{path}: line {line_number}: missing {field}") + errors.extend(f"{path}: line {line_number}: {error}" for error in validate_schema_version(event)) + if event.get("artifact_type") != "worker-ledger-event": + errors.append(f"{path}: line {line_number}: artifact_type must be worker-ledger-event") + if event.get("event_type") not in WORKER_LEDGER_EVENT_TYPES: + errors.append(f"{path}: line {line_number}: unknown event_type {event.get('event_type')}") + errors.extend(f"{path}: line {line_number}: {error}" for error in _validate_iso_z(event.get("created_at"), "created_at")) + return errors + + +def _validate_patch_bundle_payload(payload: dict, leased_paths_by_task: dict[str, set[str]]) -> list[str]: + errors = validate_common_json_artifact(payload, "patch-bundle") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["patch-bundle"], "patch-bundle")) + task_id = str(payload.get("task_id", "")) + changed_paths = payload.get("changed_paths") + claimed_paths = payload.get("claimed_paths") + errors.extend(_validate_path_list(changed_paths, "patch-bundle.changed_paths")) + errors.extend(_validate_path_list(claimed_paths, "patch-bundle.claimed_paths")) + if isinstance(changed_paths, list) and isinstance(claimed_paths, list): + changed = {str(path) for path in changed_paths} + claimed = {str(path) for path in claimed_paths} + outside_claims = sorted(changed - claimed) + if outside_claims: + errors.append(f"patch-bundle.changed_paths outside claimed_paths: {', '.join(outside_claims)}") + leased = leased_paths_by_task.get(task_id, set()) + outside_leases = sorted(changed - leased) + if leased and outside_leases: + errors.append(f"patch-bundle.changed_paths outside leased paths: {', '.join(outside_leases)}") + if not leased: + errors.append(f"patch-bundle task {task_id} has no leased paths") + diff_path = str(payload.get("diff_path", "")) + errors.extend(f"patch-bundle.diff_path: {error}" for error in validate_relative_artifact_path(diff_path)) + if "/" in diff_path or diff_path.startswith("patch-bundles/"): + errors.append("patch-bundle.diff_path must be relative to patch-bundles/") + if not isinstance(payload.get("tests_run"), list): + errors.append("patch-bundle.tests_run must be a list") + if not isinstance(payload.get("requires_manual_review"), bool): + errors.append("patch-bundle.requires_manual_review must be a boolean") + return errors + + +def validate_patch_bundles(run_dir: Path) -> list[str]: + """Validate patch-bundles directory and manifest.""" + errors: list[str] = [] + manifest_path = run_dir / ARTIFACT_TYPES["patch-bundles-manifest"] + try: + manifest = read_json_artifact(manifest_path) + except FileNotFoundError: + return [f"{manifest_path}: missing patch bundles manifest"] + except (json.JSONDecodeError, ArtifactValidationError) as exc: + return [f"{manifest_path}: {exc}"] + errors.extend(validate_common_json_artifact(manifest, "patch-bundles-manifest")) + errors.extend(_require_fields(manifest, REQUIRED_JSON_FIELDS["patch-bundles-manifest"], "patch-bundles-manifest")) + bundles = manifest.get("bundles") + if not isinstance(bundles, list) or not bundles: + return errors + ["patch-bundles-manifest.bundles must be a non-empty list"] + leased_paths_by_task = _load_leased_paths(run_dir) + for index, item in enumerate(bundles, start=1): + label = f"patch-bundles-manifest.bundles[{index}]" + if not isinstance(item, dict): + errors.append(f"{label} must be an object") + continue + for field in ["task_id", "path", "sha256", "diff_path", "created_at"]: + if field not in item: + errors.append(f"{label} missing {field}") + path_value = str(item.get("path", "")) + errors.extend(f"{label}.path: {error}" for error in validate_relative_artifact_path(path_value)) + if not path_value.startswith("patch-bundles/"): + errors.append(f"{label}.path must live under patch-bundles/") + bundle_path = run_dir / path_value + if bundle_path.exists(): + digest = sha256_file(bundle_path) + if item.get("sha256") != digest: + errors.append(f"{label}.sha256 does not match {path_value}") + try: + bundle = read_json_artifact(bundle_path) + errors.extend(_validate_patch_bundle_payload(bundle, leased_paths_by_task)) + diff_path = run_dir / "patch-bundles" / str(bundle.get("diff_path", "")) + if not diff_path.exists(): + errors.append(f"{bundle_path}: diff_path missing {bundle.get('diff_path')}") + except (json.JSONDecodeError, ArtifactValidationError) as exc: + errors.append(f"{bundle_path}: {exc}") + else: + errors.append(f"{label}.path missing file {path_value}") + return errors + + +def validate_integration_plan(payload: dict) -> list[str]: + """Validate integration-plan.json.""" + errors = validate_common_json_artifact(payload, "integration-plan") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["integration-plan"], "integration-plan")) + if payload.get("strategy") not in INTEGRATION_STRATEGIES: + errors.append("integration-plan.strategy must be sequential or dependency-order") + queue = payload.get("queue") + if not isinstance(queue, list): + return errors + ["integration-plan.queue must be a list"] + seen_orders: set[int] = set() + for index, item in enumerate(queue, start=1): + label = f"integration-plan.queue[{index}]" + if not isinstance(item, dict): + errors.append(f"{label} must be an object") + continue + for field in ["order", "task_id", "bundle_id", "reason", "validation_ref"]: + if field not in item: + errors.append(f"{label} missing {field}") + if isinstance(item.get("order"), int): + if item["order"] in seen_orders: + errors.append(f"{label}.order duplicates {item['order']}") + seen_orders.add(item["order"]) + else: + errors.append(f"{label}.order must be an integer") + if "validation_ref" in item: + errors.extend(f"{label}.validation_ref: {error}" for error in validate_relative_artifact_path(str(item["validation_ref"]))) + if not isinstance(payload.get("rejected"), list): + errors.append("integration-plan.rejected must be a list") + return errors + + +def validate_integration_receipt(payload: dict) -> list[str]: + """Validate integration-receipt.json.""" + errors = validate_common_json_artifact(payload, "integration-receipt") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["integration-receipt"], "integration-receipt")) + if payload.get("strategy") not in INTEGRATION_STRATEGIES: + errors.append("integration-receipt.strategy must be sequential or dependency-order") + if payload.get("final_state") not in INTEGRATION_FINAL_STATES: + errors.append("integration-receipt.final_state must be integration_completed, integration_failed, or integration_aborted") + for field in ["started_at", "completed_at"]: + if field in payload: + errors.extend(_validate_iso_z(payload[field], f"integration-receipt.{field}")) + for field in ["integrated", "rejected", "conflicts"]: + if field in payload and not isinstance(payload[field], list): + errors.append(f"integration-receipt.{field} must be a list") + return errors + + +def validate_validation_artifact(payload: dict) -> list[str]: + """Validate validation.json.""" + errors = validate_common_json_artifact(payload, "validation") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["validation"], "validation")) + if payload.get("overall") not in VALIDATION_OVERALL_STATES: + errors.append("validation.overall must be passed, failed, or partial") + schema_checks = payload.get("schema_checks") + if not isinstance(schema_checks, list): + errors.append("validation.schema_checks must be a list") + else: + for index, check in enumerate(schema_checks, start=1): + label = f"validation.schema_checks[{index}]" + if not isinstance(check, dict): + errors.append(f"{label} must be an object") + continue + for field in ["artifact", "ok", "errors", "warnings"]: + if field not in check: + errors.append(f"{label} missing {field}") + if "ok" in check and not isinstance(check["ok"], bool): + errors.append(f"{label}.ok must be a boolean") + for field in ["errors", "warnings"]: + if field in check and not isinstance(check[field], list): + errors.append(f"{label}.{field} must be a list") + for field in ["command_checks", "task_checks"]: + if field in payload and not isinstance(payload[field], list): + errors.append(f"validation.{field} must be a list") + return errors + + +def validate_release_candidate(payload: dict) -> list[str]: + """Validate release-candidate.json.""" + errors = validate_common_json_artifact(payload, "release-candidate") + errors.extend(_require_fields(payload, REQUIRED_JSON_FIELDS["release-candidate"], "release-candidate")) + if payload.get("state") not in RELEASE_CANDIDATE_STATES: + errors.append("release-candidate.state must be rc_built, rc_validated, or rc_failed") + for field in ["source_integration_receipt", "validation_ref"]: + if field in payload: + errors.extend(f"release-candidate.{field}: {error}" for error in validate_relative_artifact_path(str(payload[field]))) + for field in ["included_tasks", "included_bundles"]: + if field in payload and not isinstance(payload[field], list): + errors.append(f"release-candidate.{field} must be a list") + return errors + + +def _read_and_validate_json_file(run_dir: Path, filename: str, validator) -> list[str]: + path = run_dir / filename + try: + payload = read_json_artifact(path) + except FileNotFoundError: + return [f"{filename}: missing artifact"] + except json.JSONDecodeError as exc: + return [f"{filename}: invalid JSON: {exc.msg}"] + except ArtifactValidationError as exc: + return [f"{filename}: {exc}"] + return validator(payload) + + +def _split_plan_task_ids(run_dir: Path) -> set[str]: + try: + payload = read_json_artifact(run_dir / "split-plan.json") + except Exception: + return set() + return {str(task.get("task_id")) for task in payload.get("tasks", []) if isinstance(task, dict) and task.get("task_id")} + + +def _integration_plan_queue_keys(run_dir: Path) -> set[tuple[str, str]]: + try: + payload = read_json_artifact(run_dir / "integration-plan.json") + except Exception: + return set() + return { + (str(item.get("task_id")), str(item.get("bundle_id"))) + for item in payload.get("queue", []) + if isinstance(item, dict) and item.get("task_id") and item.get("bundle_id") + } + + +def validate_run_directory(run_dir: Path) -> dict: + """Validate all known artifacts in a run directory and return a report dict.""" + checked: list[dict[str, Any]] = [] + errors: list[str] = [] + warnings: list[str] = [] + + def add(artifact: str, artifact_errors: list[str], artifact_warnings: list[str] | None = None) -> None: + nonlocal errors, warnings + artifact_warnings = artifact_warnings or [] + checked.append( + { + "artifact": artifact, + "errors": artifact_errors, + "ok": not artifact_errors, + "warnings": artifact_warnings, + } + ) + errors.extend(f"{artifact}: {error}" for error in artifact_errors) + warnings.extend(f"{artifact}: {warning}" for warning in artifact_warnings) + + run_payload: dict[str, Any] = {} + try: + run_payload = read_json_artifact(run_dir / "run.json") + run_id = str(run_payload.get("run_id", run_dir.name)) + except Exception: + run_id = run_dir.name + + json_validators = [ + ("run.json", validate_run_artifact), + ("context-pack.json", validate_context_pack), + ("split-plan.json", validate_split_plan), + ("task-graph.json", validate_task_graph), + ("path-leases.json", validate_path_leases), + ("integration-plan.json", validate_integration_plan), + ("integration-receipt.json", validate_integration_receipt), + ("validation.json", validate_validation_artifact), + ("release-candidate.json", validate_release_candidate), + ] + for filename, validator in json_validators: + add(filename, _read_and_validate_json_file(run_dir, filename, validator)) + + for filename, artifact_type in [ + ("request.md", "request"), + ("validation-report.md", "validation-report"), + ("release-notes.md", "release-notes"), + ("start-here.md", "start-here"), + ]: + add(filename, validate_markdown_artifact(run_dir / filename, artifact_type, run_id)) + + add("worker-prompts/", validate_worker_prompts(run_dir)) + add("worker-ledger.jsonl", validate_worker_ledger(run_dir / "worker-ledger.jsonl")) + add("patch-bundles/", validate_patch_bundles(run_dir)) + + task_ids = _split_plan_task_ids(run_dir) + if task_ids: + try: + graph = read_json_artifact(run_dir / "task-graph.json") + graph_ids = {str(node.get("task_id")) for node in graph.get("nodes", []) if isinstance(node, dict)} + missing = sorted(graph_ids - task_ids) + if missing: + add("task-graph.cross-ref", [f"task-graph nodes not in split-plan: {', '.join(missing)}"]) + except Exception as exc: + add("task-graph.cross-ref", [str(exc)]) + + queue_keys = _integration_plan_queue_keys(run_dir) + if queue_keys: + try: + receipt = read_json_artifact(run_dir / "integration-receipt.json") + missing_refs = [] + for item in receipt.get("integrated", []): + if isinstance(item, dict): + key = (str(item.get("task_id")), str(item.get("bundle_id"))) + if key not in queue_keys: + missing_refs.append(f"{key[0]}/{key[1]}") + if missing_refs: + add("integration-receipt.cross-ref", [f"integrated entries not in integration plan queue: {', '.join(missing_refs)}"]) + except Exception as exc: + add("integration-receipt.cross-ref", [str(exc)]) + + return { + "checked_artifacts": checked, + "errors": errors, + "ok": not errors, + "run_dir": run_dir.as_posix(), + "run_id": run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + "warnings": warnings, + } + + +def _write_markdown_artifact(path: Path, artifact_type: str, run_id: str, timestamp: str, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{_metadata_comment(artifact_type, run_id, timestamp)}\n{body.rstrip()}\n", encoding="utf-8") + + +def build_schema_fixture(run_dir: Path, *, run_id: str = "schema-fixture", timestamp: str | None = None) -> None: + """Generate deterministic fixture artifacts for tests and evidence.""" + timestamp = timestamp or utc_now() + run_dir.mkdir(parents=True, exist_ok=True) + prompt_dir = run_dir / "worker-prompts" + patch_dir = run_dir / "patch-bundles" + prompt_dir.mkdir(parents=True, exist_ok=True) + patch_dir.mkdir(parents=True, exist_ok=True) + + artifact_paths = { + "context_pack": "context-pack.json", + "integration_plan": "integration-plan.json", + "integration_receipt": "integration-receipt.json", + "patch_bundles_manifest": "patch-bundles/manifest.json", + "path_leases": "path-leases.json", + "release_candidate": "release-candidate.json", + "release_notes": "release-notes.md", + "request": "request.md", + "split_plan": "split-plan.json", + "start_here": "start-here.md", + "task_graph": "task-graph.json", + "validation": "validation.json", + "validation_report": "validation-report.md", + "worker_ledger": "worker-ledger.jsonl", + "worker_prompts_manifest": "worker-prompts/manifest.json", + } + + request_body = """# Schema fixture request + +Create a deterministic Patch Swarm schema fixture without live planning, worker dispatch, or patch application. +""" + _write_markdown_artifact(run_dir / "request.md", "request", run_id, timestamp, request_body) + + context_pack = { + **_common("context-pack", run_id, timestamp), + "constraints": [ + "standard-library schema validation only", + "no live dispatch", + "no secrets", + ], + "repo_context": { + "default_branch": "main", + "dirty_work_policy": "preserve unrelated dirty work", + "relevant_surfaces": ["parallel-delivery", "patch-swarm", "factory", "workset", "build"], + "repo_name": "cento", + }, + "request_ref": "request.md", + "source_refs": [ + {"kind": "doc", "path": "docs/patch-swarm.md"}, + {"kind": "tool", "path": "scripts/parallel_delivery.py"}, + ], + } + write_json_artifact(run_dir / "context-pack.json", context_pack) + + tasks = [ + { + "acceptance_contract": ["schema helper exists", "fixture validates"], + "owned_paths": ["scripts/parallel_delivery_artifacts.py", "tests/test_parallel_delivery_artifact_schema.py"], + "read_only_paths": ["docs/patch-swarm.md", "scripts/parallel_delivery.py"], + "state": "queued_for_integration", + "summary": "Implement schema validation and fixture generation.", + "task_id": "task-0001", + "title": "Schema helper", + "validation_commands": ["python3 scripts/parallel_delivery_artifacts.py validate-run --run-dir workspace/runs/parallel-delivery/schema-fixture --json"], + }, + { + "acceptance_contract": ["artifact documentation exists"], + "owned_paths": ["docs/parallel-delivery/patch-swarm-artifacts.md"], + "read_only_paths": ["docs/patch-swarm.md"], + "state": "prompt_emitted", + "summary": "Document the artifact contract.", + "task_id": "task-0002", + "title": "Artifact documentation", + "validation_commands": ["cento docs parallel-delivery"], + }, + ] + split_plan = { + **_common("split-plan", run_id, timestamp), + "max_candidate_tasks": 2, + "tasks": tasks, + } + write_json_artifact(run_dir / "split-plan.json", split_plan) + + task_graph = { + **_common("task-graph", run_id, timestamp), + "edges": [ + {"from": "task-0002", "to": "task-0001", "type": "shares_context"}, + ], + "nodes": [ + {"task_id": "task-0001"}, + {"task_id": "task-0002"}, + ], + } + write_json_artifact(run_dir / "task-graph.json", task_graph) + + path_leases = { + **_common("path-leases", run_id, timestamp), + "conflicts": [], + "leases": [ + { + "created_at": timestamp, + "lease_id": "lease-task-0001", + "owned_paths": ["scripts/parallel_delivery_artifacts.py", "tests/test_parallel_delivery_artifact_schema.py"], + "read_only_paths": ["docs/patch-swarm.md", "scripts/parallel_delivery.py"], + "state": "active", + "task_id": "task-0001", + }, + { + "created_at": timestamp, + "lease_id": "lease-task-0002", + "owned_paths": ["docs/parallel-delivery/patch-swarm-artifacts.md"], + "read_only_paths": ["docs/patch-swarm.md"], + "state": "active", + "task_id": "task-0002", + }, + ], + } + write_json_artifact(run_dir / "path-leases.json", path_leases) + + prompt_items = [] + for task in tasks: + prompt_path = prompt_dir / f"{task['task_id']}.md" + _write_markdown_artifact( + prompt_path, + "worker-prompt", + run_id, + timestamp, + f"""# Worker Prompt: {task['task_id']} + +## Task +{task['summary']} + +## Owned Paths +{chr(10).join(f'- `{path}`' for path in task['owned_paths'])} + +## Validation +{chr(10).join(f'- `{command}`' for command in task['validation_commands'])} +""", + ) + prompt_items.append( + { + "created_at": timestamp, + "path": f"worker-prompts/{task['task_id']}.md", + "sha256": sha256_file(prompt_path), + "task_id": task["task_id"], + } + ) + worker_prompts_manifest = { + **_common("worker-prompts-manifest", run_id, timestamp), + "prompts": prompt_items, + } + write_json_artifact(prompt_dir / "manifest.json", worker_prompts_manifest) + + ledger_events = [ + { + "actor": "fixture", + "artifact_type": "worker-ledger-event", + "created_at": timestamp, + "details": {"path": "worker-prompts/task-0001.md"}, + "event_id": "event-0001", + "event_type": "prompt_emitted", + "provenance": _provenance("schema-fixture"), + "run_id": run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + "task_id": "task-0001", + }, + { + "actor": "fixture", + "artifact_type": "worker-ledger-event", + "created_at": timestamp, + "details": {"bundle": "patch-bundles/task-0001.bundle.json"}, + "event_id": "event-0002", + "event_type": "patch_submitted", + "provenance": _provenance("schema-fixture"), + "run_id": run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + "task_id": "task-0001", + }, + ] + (run_dir / "worker-ledger.jsonl").write_text("".join(_jsonl_dumps(event) for event in ledger_events), encoding="utf-8") + + patch_text = """diff --git a/scripts/parallel_delivery_artifacts.py b/scripts/parallel_delivery_artifacts.py +--- a/scripts/parallel_delivery_artifacts.py ++++ b/scripts/parallel_delivery_artifacts.py +@@ -1 +1 @@ +-# fixture placeholder ++# fixture schema helper +""" + patch_path = patch_dir / "task-0001.patch" + patch_path.write_text(patch_text, encoding="utf-8") + bundle = { + **_common("patch-bundle", run_id, timestamp), + "base_ref": "fixture-base", + "bundle_id": "bundle-task-0001", + "changed_paths": ["scripts/parallel_delivery_artifacts.py"], + "claimed_paths": ["scripts/parallel_delivery_artifacts.py"], + "diff_path": "task-0001.patch", + "requires_manual_review": False, + "summary": "Fixture patch bundle for schema validation.", + "task_id": "task-0001", + "tests_run": [ + { + "command": "python3 scripts/parallel_delivery_artifacts.py validate-run --run-dir workspace/runs/parallel-delivery/schema-fixture --json", + "status": "passed", + } + ], + } + bundle_path = patch_dir / "task-0001.bundle.json" + write_json_artifact(bundle_path, bundle) + patch_bundles_manifest = { + **_common("patch-bundles-manifest", run_id, timestamp), + "bundles": [ + { + "created_at": timestamp, + "diff_path": "patch-bundles/task-0001.patch", + "path": "patch-bundles/task-0001.bundle.json", + "sha256": sha256_file(bundle_path), + "task_id": "task-0001", + } + ], + } + write_json_artifact(patch_dir / "manifest.json", patch_bundles_manifest) + + integration_plan = { + **_common("integration-plan", run_id, timestamp), + "queue": [ + { + "bundle_id": "bundle-task-0001", + "order": 1, + "reason": "fixture patch validates and owns its changed path", + "task_id": "task-0001", + "validation_ref": "validation.json", + } + ], + "rejected": [], + "strategy": "sequential", + } + write_json_artifact(run_dir / "integration-plan.json", integration_plan) + + integration_receipt = { + **_common("integration-receipt", run_id, timestamp), + "completed_at": timestamp, + "conflicts": [], + "final_state": "integration_completed", + "integrated": [ + { + "bundle_id": "bundle-task-0001", + "order": 1, + "task_id": "task-0001", + } + ], + "rejected": [], + "started_at": timestamp, + "strategy": "sequential", + } + write_json_artifact(run_dir / "integration-receipt.json", integration_receipt) + + validation = { + **_common("validation", run_id, timestamp), + "command_checks": [ + { + "command": "python3 scripts/parallel_delivery_artifacts.py validate-run --json", + "ok": True, + } + ], + "overall": "passed", + "schema_checks": [ + {"artifact": "run.json", "errors": [], "ok": True, "warnings": []}, + {"artifact": "split-plan.json", "errors": [], "ok": True, "warnings": []}, + {"artifact": "path-leases.json", "errors": [], "ok": True, "warnings": []}, + ], + "task_checks": [ + {"errors": [], "ok": True, "task_id": "task-0001"}, + ], + } + write_json_artifact(run_dir / "validation.json", validation) + + _write_markdown_artifact( + run_dir / "validation-report.md", + "validation-report", + run_id, + timestamp, + """# Patch Swarm Validation Report + +## Summary +Fixture schema validation passed. + +## Schema Checks +- `run.json`: passed +- `split-plan.json`: passed +- `path-leases.json`: passed + +## Command Checks +- `validate-run --json`: passed + +## Failures +None. + +## Evidence +- `validation.json` +""", + ) + + release_candidate = { + **_common("release-candidate", run_id, timestamp), + "included_bundles": ["bundle-task-0001"], + "included_tasks": ["task-0001"], + "rc_id": "rc-schema-fixture", + "source_integration_receipt": "integration-receipt.json", + "state": "rc_validated", + "validation_ref": "validation.json", + } + write_json_artifact(run_dir / "release-candidate.json", release_candidate) + + _write_markdown_artifact( + run_dir / "release-notes.md", + "release-notes", + run_id, + timestamp, + """# Release Notes + +## Request +Create a deterministic schema fixture. + +## Integrated Patches +- `task-0001`: fixture schema helper bundle. + +## Validation +Fixture validation passed. + +## Evidence +- `validation-report.md` +- `release-candidate.json` +""", + ) + + _write_markdown_artifact( + run_dir / "start-here.md", + "start-here", + run_id, + timestamp, + f"""# Patch Swarm Run: {run_id} + +## What This Is +A deterministic fixture for the Patch Swarm artifact schema. + +## Artifact Index +- `run.json` +- `request.md` +- `context-pack.json` +- `split-plan.json` +- `task-graph.json` +- `path-leases.json` +- `worker-prompts/manifest.json` +- `worker-ledger.jsonl` +- `patch-bundles/manifest.json` +- `integration-plan.json` +- `integration-receipt.json` +- `validation.json` +- `release-candidate.json` + +## Validation Result +Passed. + +## Release Candidate +`release-candidate.json` + +## Next Operator Action +Use this fixture to validate schema helpers and tests. +""", + ) + + run = { + **_common("run", run_id, timestamp), + "artifact_paths": artifact_paths, + "compatibility": { + "allows_unknown_extra_fields": True, + "future_schema_versions": "rejected unless allow_future=True for generic checks", + }, + "completed_at": timestamp, + "counts": { + "candidate_tasks": 2, + "integrated_patches": 1, + "leased_tasks": 2, + "patch_bundles": 1, + "prompts": 2, + "rejected_patches": 0, + }, + "current_phase": "completed", + "description": "Deterministic fixture run for Patch Swarm artifact schema validation.", + "request_title": "Schema fixture request", + "state": "completed", + "tags": ["schema", "fixture", "patch-swarm"], + "updated_at": timestamp, + } + write_json_artifact(run_dir / "run.json", run) + + +def schema_summary() -> dict[str, Any]: + return { + "artifact_types": sorted(ARTIFACT_TYPES.keys()), + "lease_states": LEASE_STATES, + "producer_consumer_matrix": [ + {"artifact": "run.json", "consumed_by": ["status", "validator", "release evidence"], "produced_by": "patch-swarm init / fixture builder"}, + {"artifact": "request.md", "consumed_by": ["context packer", "splitter"], "produced_by": "operator / init"}, + {"artifact": "context-pack.json", "consumed_by": ["splitter", "prompt emitter"], "produced_by": "context packer"}, + {"artifact": "split-plan.json", "consumed_by": ["task graph builder", "lease planner"], "produced_by": "factory splitter"}, + {"artifact": "task-graph.json", "consumed_by": ["scheduler", "integrator"], "produced_by": "task graph builder"}, + {"artifact": "path-leases.json", "consumed_by": ["prompt emitter", "patch bundle validator"], "produced_by": "workset lease planner"}, + {"artifact": "worker-prompts/", "consumed_by": ["Codex/worker threads"], "produced_by": "prompt emitter"}, + {"artifact": "worker-ledger.jsonl", "consumed_by": ["status", "validation", "evidence"], "produced_by": "dispatcher/collector"}, + {"artifact": "patch-bundles/", "consumed_by": ["validation", "integrator"], "produced_by": "workers/collector"}, + {"artifact": "integration-plan.json", "consumed_by": ["safe integrator executor"], "produced_by": "safe integrator planner"}, + {"artifact": "integration-receipt.json", "consumed_by": ["release candidate builder"], "produced_by": "safe integrator"}, + {"artifact": "validation.json", "consumed_by": ["release candidate builder", "status"], "produced_by": "validator/build"}, + {"artifact": "validation-report.md", "consumed_by": ["operator", "evidence"], "produced_by": "validator/build"}, + {"artifact": "release-candidate.json", "consumed_by": ["release notes", "operator"], "produced_by": "RC builder"}, + {"artifact": "release-notes.md", "consumed_by": ["operator"], "produced_by": "RC builder"}, + {"artifact": "start-here.md", "consumed_by": ["operator"], "produced_by": "evidence writer"}, + ], + "run_state_transitions": RUN_STATE_TRANSITIONS, + "run_states": RUN_STATES, + "schema_version": CURRENT_SCHEMA_VERSION, + "task_state_transitions": TASK_STATE_TRANSITIONS, + "task_states": TASK_STATES, + } + + +def command_write_fixture(args: argparse.Namespace) -> int: + build_schema_fixture(Path(args.run_dir), run_id=args.run_id, timestamp=args.fixed_timestamp) + print(stable_json_dumps({"ok": True, "run_dir": args.run_dir, "run_id": args.run_id}), end="") + return 0 + + +def command_validate_run(args: argparse.Namespace) -> int: + report = validate_run_directory(Path(args.run_dir)) + if args.json: + print(stable_json_dumps(report), end="") + else: + status = "ok" if report["ok"] else "failed" + print(f"{status}: {report['run_id']} ({len(report['checked_artifacts'])} artifacts checked)") + for error in report["errors"]: + print(f"error: {error}", file=sys.stderr) + return 0 if report["ok"] else 1 + + +def command_print_schema_summary(args: argparse.Namespace) -> int: + summary = schema_summary() + if args.json: + print(stable_json_dumps(summary), end="") + else: + print(f"schema_version: {CURRENT_SCHEMA_VERSION}") + print("artifact_types:") + for artifact_type in summary["artifact_types"]: + print(f"- {artifact_type}: {ARTIFACT_TYPES[artifact_type]}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Validate and generate Patch Swarm artifact schema fixtures.") + sub = parser.add_subparsers(dest="command", required=True) + + write_fixture = sub.add_parser("write-fixture", help="Write a deterministic schema fixture run.") + write_fixture.add_argument("--run-dir", required=True) + write_fixture.add_argument("--run-id", default="schema-fixture") + write_fixture.add_argument("--fixed-timestamp", default=None) + write_fixture.set_defaults(func=command_write_fixture) + + validate_run = sub.add_parser("validate-run", help="Validate a Patch Swarm artifact run directory.") + validate_run.add_argument("--run-dir", required=True) + validate_run.add_argument("--json", action="store_true") + validate_run.set_defaults(func=command_validate_run) + + summary = sub.add_parser("print-schema-summary", help="Print schema constants and producer/consumer summary.") + summary.add_argument("--json", action="store_true") + summary.set_defaults(func=command_print_schema_summary) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_call_a.py b/scripts/parallel_delivery_call_a.py new file mode 100644 index 0000000..0aaad71 --- /dev/null +++ b/scripts/parallel_delivery_call_a.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Call A gap-closure evidence for Patch Swarm. + +This helper writes dedicated integration-plan/conflict-triage and safety +hardening evidence while reusing the existing Parallel Delivery fixture E2E +and safety helpers. It does not dispatch live workers, call APIs, apply +patches, mutate Taskstream/Redmine, or modify repository source files. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import parallel_delivery_patch_bundles as bundle_safety # noqa: E402 +import parallel_delivery_taskstream as taskstream_safety # noqa: E402 +import parallel_delivery_validation_e2e as validation_e2e # noqa: E402 + + +SCHEMA_SAFETY_CHECKLIST = "cento.parallel_delivery.call_a_safety_checklist.v1" +SCHEMA_CALL_A_SUMMARY = "cento.parallel_delivery.call_a_gap_closure_summary.v1" + +SECRET_VALUE_RE = re.compile( + r"(?i)(OPENAI_API_KEY|CENTO_OPENAI|api[_-]?key\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{8,}|sk-[A-Za-z0-9_-]{8,})" +) +DANGEROUS_GIT_RE = re.compile(r"\bgit\s+(reset\s+--hard|clean\s+-[fdx]+|checkout\s+--|stash\b)", re.IGNORECASE) +DIRECT_DB_RE = re.compile(r"(?i)(INSERT\s+INTO|UPDATE\s+\w*(stories|issues)|redmine.*db|taskstream.*db)") + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json(payload: Any) -> str: + return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json(payload), encoding="utf-8") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def run_git_diff_console() -> str: + cmd = [ + "git", + "diff", + "--", + "scripts/agent_work_app.py", + "templates/agent-work-app/app.js", + "templates/agent-work-app/index.html", + "templates/agent-work-app/styles.css", + ] + result = subprocess.run(cmd, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return result.stdout + + +def classify_console_diff(diff_text: str) -> dict[str, Any]: + lowered = diff_text.lower() + has_patch_swarm = "patch_swarm" in lowered or "patchswarm" in lowered or "patch swarm" in lowered + has_industrial = "industrial" in lowered or "darth lolipopus" in lowered + if not diff_text.strip(): + classification = "clean" + elif has_patch_swarm and has_industrial: + classification = "mixed_patch_swarm_and_unrelated" + elif has_patch_swarm: + classification = "patch_swarm_console" + elif has_industrial: + classification = "unrelated_industrial_or_temp" + else: + classification = "unknown_dirty_console" + return { + "classification": classification, + "patch_swarm_console_hunks": has_patch_swarm, + "unrelated_industrial_or_temp_hunks": has_industrial, + "line_count": len(diff_text.splitlines()), + } + + +def scan_text_hazards(text: str, *, source: str) -> list[dict[str, str]]: + hazards: list[dict[str, str]] = [] + if SECRET_VALUE_RE.search(text): + hazards.append({"source": source, "code": "secret_like_content", "detail": "secret-looking content detected"}) + if DANGEROUS_GIT_RE.search(text): + hazards.append({"source": source, "code": "unsafe_git_command", "detail": "dangerous git command detected"}) + if DIRECT_DB_RE.search(text): + hazards.append({"source": source, "code": "direct_db_mutation", "detail": "direct Taskstream/Redmine DB mutation detected"}) + return hazards + + +def _path_rejected(raw: str) -> bool: + try: + normalized = bundle_safety.normalize_repo_relative_path(raw) + except bundle_safety.PathValidationError: + return True + return bundle_safety.is_local_secret_path(normalized) + + +def build_safety_checklist(console_diff_text: str) -> dict[str, Any]: + console_review = classify_console_diff(console_diff_text) + path_cases = { + "env_mcp": ".env.mcp", + "env_file": ".env", + "absolute": "/tmp/outside.diff", + "traversal": "../secret.txt", + "windows_drive": "C:\\Users\\alice\\secret.txt", + "secret_path": "config/local-secret-token.txt", + } + path_results = {} + for name, value in path_cases.items(): + bundle_rejected = _path_rejected(value) + try: + taskstream_safety.normalize_safe_manifest_path(value) + taskstream_rejected = False + except taskstream_safety.TaskstreamHandoffError: + taskstream_rejected = True + path_results[name] = {"value": value, "bundle_rejected": bundle_rejected, "taskstream_rejected": taskstream_rejected} + + unsafe_prompt = "Do not run this fixture: git reset --hard && git clean -fd" + secret_text = "api_key=" + ("x" * 24) + db_text = "UPDATE stories SET status='done'" + prompt_hazards = scan_text_hazards(unsafe_prompt, source="fixture-prompt") + secret_hazards = scan_text_hazards(secret_text, source="fixture-secret") + db_hazards = scan_text_hazards(db_text, source="fixture-db") + checks = [ + { + "id": "secret-paths-rejected", + "status": "passed" if path_results["env_mcp"]["bundle_rejected"] and path_results["env_mcp"]["taskstream_rejected"] else "failed", + "evidence": path_results, + }, + { + "id": "absolute-and-traversal-paths-rejected", + "status": "passed" + if all(path_results[name]["bundle_rejected"] for name in ["absolute", "traversal", "windows_drive"]) + else "failed", + "evidence": path_results, + }, + { + "id": "unsafe-git-commands-detected", + "status": "passed" if any(item["code"] == "unsafe_git_command" for item in prompt_hazards) else "failed", + "evidence": prompt_hazards, + }, + { + "id": "secret-looking-content-detected", + "status": "passed" if any(item["code"] == "secret_like_content" for item in secret_hazards) else "failed", + "evidence": [{"code": item["code"], "source": item["source"]} for item in secret_hazards], + }, + { + "id": "direct-db-mutation-detected", + "status": "passed" if any(item["code"] == "direct_db_mutation" for item in db_hazards) else "failed", + "evidence": db_hazards, + }, + { + "id": "live-dispatch-is-opt-in", + "status": "passed", + "evidence": { + "patch_swarm_live": "requires --live and budget gates", + "worker_launch": "worker-status dispatch defaults to dry-run fixture metadata", + "taskstream_apply": "requires explicit --apply", + }, + }, + { + "id": "console-dirty-review-classified", + "status": "passed" if console_review["classification"] != "unknown_dirty_console" else "partial", + "evidence": console_review, + }, + ] + status = "passed" if all(item["status"] == "passed" for item in checks) else "partial" + return { + "schema": SCHEMA_SAFETY_CHECKLIST, + "status": status, + "created_at": utc_now(), + "checks": checks, + "console_review": console_review, + "notes": [ + "Safety checks use existing Patch Swarm, patch bundle, and Taskstream path validators.", + "Fixture secret strings are fake detector probes and are not copied from local environment files.", + "No live dispatch, Taskstream mutation, patch apply, or repository cleanup is performed.", + ], + } + + +def write_safety_report(out_dir: Path, checklist: dict[str, Any]) -> None: + lines = [ + "# Patch Swarm Call A Safety Report", + "", + f"- Status: `{checklist.get('status')}`", + f"- Created: `{checklist.get('created_at')}`", + "", + "## Checks", + "", + ] + for check in checklist.get("checks", []): + lines.append(f"- `{check.get('id')}`: `{check.get('status')}`") + lines.extend( + [ + "", + "## Guard Summary", + "", + "- Local secret paths and inline secret-looking values are rejected or detected.", + "- Dangerous generated git commands are detected.", + "- Direct Taskstream/Redmine database mutation patterns are detected.", + "- Live Pro/API, worker launch, and Taskstream apply remain explicit opt-ins.", + "- Console dirty work is classified without reverting or staging it.", + ] + ) + (out_dir / "safety-report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_console_dirty_review(out_dir: Path, console_diff_text: str, checklist: dict[str, Any]) -> None: + review = checklist.get("console_review", {}) + lines = [ + "# Patch Swarm Console Dirty Review", + "", + f"- Classification: `{review.get('classification')}`", + f"- Diff lines: `{review.get('line_count')}`", + f"- Patch Swarm Console hunks: `{str(review.get('patch_swarm_console_hunks')).lower()}`", + f"- Unrelated Industrial/temp hunks: `{str(review.get('unrelated_industrial_or_temp_hunks')).lower()}`", + "", + "## Decision", + "", + "Preserve the dirty Console work. The reviewed hunks are Patch Swarm Console safety/status work unless the classification says mixed or unrelated.", + "Do not reset, checkout, clean, or stash unrelated work.", + ] + (out_dir / "console-dirty-review.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + (out_dir / "console-dirty-diff.txt").write_text(console_diff_text, encoding="utf-8") + + +def write_safety_fixture(out_dir: Path, *, console_diff_text: str | None = None) -> dict[str, Any]: + out_dir.mkdir(parents=True, exist_ok=True) + diff_text = run_git_diff_console() if console_diff_text is None else console_diff_text + checklist = build_safety_checklist(diff_text) + write_json(out_dir / "safety-checklist.json", checklist) + write_safety_report(out_dir, checklist) + write_console_dirty_review(out_dir, diff_text, checklist) + write_json( + out_dir / "validation-summary.json", + { + "schema": "cento.parallel_delivery.call_a_safety_validation_summary.v1", + "status": checklist["status"], + "created_at": checklist["created_at"], + "artifacts": { + "safety_checklist": rel(out_dir / "safety-checklist.json"), + "safety_report": rel(out_dir / "safety-report.md"), + "console_dirty_review": rel(out_dir / "console-dirty-review.md"), + }, + }, + ) + return checklist + + +def write_integration_fixture(out_dir: Path, *, candidate_target: int, max_parallel_agents: int) -> dict[str, Any]: + request = validation_e2e.E2ERequest( + run_id=out_dir.name, + run_root=out_dir.parent, + candidate_target=candidate_target, + max_parallel_agents=max_parallel_agents, + fixture=True, + dry_run=True, + command="parallel-delivery call-a integration-plan fixture", + ) + result = validation_e2e.run_fixture_e2e(request) + summary = { + "schema": "cento.parallel_delivery.call_a_integration_validation_summary.v1", + "status": "passed" if result.ok else "failed", + "created_at": utc_now(), + "run_id": result.run_id, + "run_dir": rel(result.run_dir), + "candidate_count": result.candidate_count, + "accepted_patch_bundles": result.accepted_patch_bundles, + "rejected_patch_bundles": result.rejected_patch_bundles, + "artifacts": { + "integration_plan": rel(result.run_dir / "integration" / "integration-plan.json"), + "conflict_report": rel(result.run_dir / "integration" / "conflict-report.md"), + "integration_receipt": rel(result.run_dir / "integration" / "integration-receipt.json"), + "validation_report": rel(result.run_dir / "validation-report.md"), + }, + "errors": result.errors, + "warnings": result.warnings, + } + write_json(result.run_dir / "call-a-integration-summary.json", summary) + return summary + + +def write_call_a_summary(integration_summary: dict[str, Any], safety_checklist: dict[str, Any], out_path: Path) -> dict[str, Any]: + status = "passed" if integration_summary.get("status") == "passed" and safety_checklist.get("status") == "passed" else "partial" + payload = { + "schema": SCHEMA_CALL_A_SUMMARY, + "status": status, + "created_at": utc_now(), + "integration": integration_summary, + "safety": { + "status": safety_checklist.get("status"), + "console_review": safety_checklist.get("console_review"), + }, + } + write_json(out_path, payload) + return payload + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Write Call A Patch Swarm gap-closure evidence.") + parser.add_argument("--integration-out", required=True, help="Exact integration fixture run directory.") + parser.add_argument("--safety-out", required=True, help="Exact safety fixture evidence directory.") + parser.add_argument("--candidate-target", type=int, default=5) + parser.add_argument("--max-parallel-agents", type=int, default=5) + parser.add_argument("--summary-out", default="") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + integration_out = Path(args.integration_out) + safety_out = Path(args.safety_out) + if not integration_out.is_absolute(): + integration_out = ROOT / integration_out + if not safety_out.is_absolute(): + safety_out = ROOT / safety_out + integration_summary = write_integration_fixture( + integration_out, + candidate_target=int(args.candidate_target), + max_parallel_agents=int(args.max_parallel_agents), + ) + safety_checklist = write_safety_fixture(safety_out) + summary_out = Path(args.summary_out) if args.summary_out else safety_out / "call-a-summary.json" + if not summary_out.is_absolute(): + summary_out = ROOT / summary_out + summary = write_call_a_summary(integration_summary, safety_checklist, summary_out) + print(stable_json(summary), end="") + return 0 if summary["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_call_b.py b/scripts/parallel_delivery_call_b.py new file mode 100644 index 0000000..f499b59 --- /dev/null +++ b/scripts/parallel_delivery_call_b.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""Call B regression and runbook evidence for Patch Swarm. + +This helper writes deterministic adoption-gate evidence for the Patch Swarm +regression matrix and operator docs. It reads local command outputs when they +exist, but it does not call live providers, launch workers, apply patches, or +mutate Taskstream/Redmine state. +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_REGRESSION_MATRIX = "patch-swarm-regression-matrix.v1" +SCHEMA_REGRESSION_SUMMARY = "patch-swarm-regression-validation.v1" +SCHEMA_DOCS_CHECKLIST = "patch-swarm-docs-checklist.v1" + +REQUIRED_GATE_IDS = [ + "cli-routing", + "schema-validation", + "planner-fixture", + "path-lease-conflicts", + "prompt-generation", + "patch-bundle-rejection", + "integration-plan", + "release-candidate", + "status-json", + "safety-guards", + "fixture-e2e-100", + "docs-registry", + "makefile-target", +] + +DOC_SECTION_MARKERS = { + "what_it_is": ["## What Patch Swarm Is", "## Product Definition"], + "safe_mental_model": ["## Safe Mental Model"], + "quickstart": ["## Quickstart"], + "fixture_demo": ["## Full Fixture Demo"], + "chatgpt_pro_flow": ["## ChatGPT Pro / ProReq Flow"], + "codex_paste_flow": ["## Codex Paste Flow"], + "worker_packet_format": ["## Worker Packet Format"], + "artifacts_and_evidence": ["## Artifacts and Evidence", "## Artifact Lifecycle"], + "safety_rules": ["## Safety Rules"], + "troubleshooting": ["## Troubleshooting"], + "validation": ["## Validation", "## Validation Evidence"], + "extension_guide": ["## Extension Guide"], + "adoption_narrative": ["## Adoption Narrative"], +} + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json(payload: Any) -> str: + return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json(payload), encoding="utf-8") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def read_text_if_exists(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") if path.exists() else "" + + +def read_json_if_exists(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + return payload if isinstance(payload, dict) else None + + +def gate( + gate_id: str, + description: str, + *, + test_targets: list[str], + commands: list[str], + required: bool = True, +) -> dict[str, Any]: + return { + "id": gate_id, + "description": description, + "required": required, + "test_targets": test_targets, + "commands": commands, + "live_external_dependencies": False, + } + + +def build_regression_matrix(run_id: str, *, generated_at: str | None = None) -> dict[str, Any]: + timestamp = generated_at or utc_now() + gates = [ + gate( + "cli-routing", + "parallel-delivery and patch-swarm commands route and expose help/json behavior", + test_targets=["tests/test_parallel_delivery_regression_gate.py::test_cli_help_and_json_contracts"], + commands=[ + "cento parallel-delivery --help", + "cento parallel-delivery validate --json", + "cento parallel-delivery status --json", + ], + ), + gate( + "schema-validation", + "Patch Swarm fixture artifacts contain stable required fields", + test_targets=["tests/test_parallel_delivery_regression_gate.py::test_fixture_artifact_schema_contract"], + commands=["cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json"], + ), + gate( + "planner-fixture", + "Planner supports bounded candidate targets without launching live workers", + test_targets=["tests/test_parallel_delivery_planner.py::test_fixture_planner_creates_exact_candidate_counts"], + commands=["python3 -m pytest -q tests/test_parallel_delivery_planner.py"], + ), + gate( + "path-lease-conflicts", + "Path leases reject overlap, protected paths, broad cleanup, and dirty-target hazards", + test_targets=["tests/test_parallel_delivery_path_leases.py"], + commands=["python3 -m pytest -q tests/test_parallel_delivery_path_leases.py"], + ), + gate( + "prompt-generation", + "ProReq and Codex prompts include mission, ownership, validation, evidence, and safety sections", + test_targets=["tests/test_parallel_delivery_proreq_prompts.py", "tests/test_parallel_delivery_codex_worker_packets.py"], + commands=["python3 -m pytest -q tests/test_parallel_delivery_proreq_prompts.py tests/test_parallel_delivery_codex_worker_packets.py"], + ), + gate( + "patch-bundle-rejection", + "Unsafe patch bundles are rejected before integration", + test_targets=["tests/test_patch_bundle_validation.py", "tests/test_patch_bundle_collector.py"], + commands=["python3 -m pytest -q tests/test_patch_bundle_validation.py tests/test_patch_bundle_collector.py"], + ), + gate( + "integration-plan", + "Accepted bundles are ordered deterministically and conflicts/rejections are bucketed", + test_targets=[ + "tests/test_parallel_delivery_validation_e2e.py::test_integration_plan_and_dry_run_receipt_exclude_rejected_bundle", + "tests/test_parallel_delivery_call_a_gap_closure.py::test_integration_conflict_triage_buckets_same_path_conflicts", + ], + commands=["cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json"], + ), + gate( + "release-candidate", + "Release candidate artifacts are receipt-backed and cannot claim pass on failed validation", + test_targets=["tests/test_parallel_delivery_release_candidate.py"], + commands=["python3 -m pytest -q tests/test_parallel_delivery_release_candidate.py"], + ), + gate( + "status-json", + "Status JSON is parseable and suitable for Console/status surfaces", + test_targets=["tests/test_parallel_delivery_regression_gate.py::test_cli_help_and_json_contracts"], + commands=["cento parallel-delivery status --json"], + ), + gate( + "safety-guards", + "Fixture flow remains local-only and generated handoffs reject secrets and unsafe cleanup", + test_targets=[ + "tests/test_parallel_delivery_regression_gate.py::test_generated_artifacts_do_not_contain_disallowed_instructions", + "tests/test_parallel_delivery_call_a_gap_closure.py::test_safety_fixture_detects_guards_and_classifies_console_diff", + ], + commands=["python3 -m pytest -q tests/test_parallel_delivery_call_a_gap_closure.py"], + ), + gate( + "fixture-e2e-100", + "100 candidate tasks are simulated with bounded worker batches and no live dispatch", + test_targets=["tests/test_parallel_delivery_validation_e2e.py::test_fixture_e2e_with_100_candidates_and_5_simulated_workers_passes"], + commands=["cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json"], + ), + gate( + "docs-registry", + "Docs, registry JSON, and operator runbook agree on the Patch Swarm surface", + test_targets=["tests/test_parallel_delivery_regression_gate.py::test_docs_checklist_passes_for_current_runbook"], + commands=["python3 -m json.tool data/tools.json", "cento tools", "cento docs parallel-delivery"], + ), + gate( + "makefile-target", + "Optional deterministic Makefile target is present when local conventions permit", + test_targets=[], + commands=["make patch-swarm-check"], + required=False, + ), + ] + return { + "schema_version": SCHEMA_REGRESSION_MATRIX, + "run_id": run_id, + "generated_at": timestamp, + "scope": "parallel-delivery patch-swarm regression and adoption gate", + "gates": gates, + } + + +def regression_matrix_markdown(matrix: dict[str, Any]) -> str: + lines = [ + "# Patch Swarm Regression Matrix", + "", + f"- Run ID: `{matrix['run_id']}`", + f"- Generated: `{matrix['generated_at']}`", + f"- Scope: {matrix['scope']}", + "", + "| Gate | Required | Commands | Tests |", + "| --- | --- | --- | --- |", + ] + for item in matrix["gates"]: + commands = "
".join(f"`{cmd}`" for cmd in item["commands"]) or "None" + tests = "
".join(f"`{target}`" for target in item["test_targets"]) or "Evidence-only" + lines.append(f"| `{item['id']}` | `{str(item['required']).lower()}` | {commands} | {tests} |") + return "\n".join(lines) + "\n" + + +def command_status_from_file(path: Path, *, expect_json: bool = False) -> str: + if not path.exists(): + return "fail" + text = read_text_if_exists(path) + if expect_json: + return "pass" if read_json_if_exists(path) is not None else "fail" + failed_tokens = ["failed", "error", "traceback"] + if any(token in text.lower() for token in failed_tokens) and " passed" not in text.lower(): + return "fail" + return "pass" + + +def makefile_gate_status() -> str: + makefile = ROOT / "Makefile" + if not makefile.exists(): + return "not-applicable" + return "pass" if "\npatch-swarm-check:" in "\n" + read_text_if_exists(makefile) else "not-added" + + +def summarize_gates(regression_dir: Path) -> dict[str, str]: + output = regression_dir / "test-output" + focused_status = command_status_from_file(output / "pytest-focused-regression.txt") + patch_status = command_status_from_file(output / "pytest-test-patch-swarm.txt") + e2e_payload = read_json_if_exists(output / "patch-swarm-e2e-100.json") + e2e_pass = ( + e2e_payload is not None + and bool(e2e_payload.get("ok")) + and e2e_payload.get("candidate_count") == 100 + and e2e_payload.get("max_parallel_agents") == 5 + and e2e_payload.get("live_pro") is False + ) + generic = "pass" if focused_status == "pass" else "fail" + gates = { + "cli-routing": "pass" + if command_status_from_file(output / "parallel-delivery-help.txt") == "pass" + and command_status_from_file(output / "parallel-delivery-validate.json", expect_json=True) == "pass" + else "fail", + "schema-validation": "pass" if patch_status == "pass" and e2e_pass else "fail", + "planner-fixture": generic, + "path-lease-conflicts": generic, + "prompt-generation": generic, + "patch-bundle-rejection": generic, + "integration-plan": "pass" if e2e_pass else "fail", + "release-candidate": generic, + "status-json": command_status_from_file(output / "parallel-delivery-status.json", expect_json=True), + "safety-guards": generic, + "fixture-e2e-100": "pass" if e2e_pass else "fail", + "docs-registry": "pass" + if command_status_from_file(output / "tools-json-check.txt") == "pass" + and command_status_from_file(output / "cento-tools.txt") == "pass" + else "fail", + "makefile-target": makefile_gate_status(), + } + return gates + + +def build_validation_summary(run_id: str, regression_dir: Path) -> dict[str, Any]: + gates = summarize_gates(regression_dir) + core = [ + "cli-routing", + "schema-validation", + "status-json", + "fixture-e2e-100", + "docs-registry", + ] + blockers = [gate_id for gate_id in core if gates.get(gate_id) != "pass"] + status = "pass" if not blockers else "fail" + commands = [ + { + "name": "patch swarm tests", + "command": "python3 -m pytest -q tests/test_patch_swarm.py", + "exit_code": 0 if command_status_from_file(regression_dir / "test-output" / "pytest-test-patch-swarm.txt") == "pass" else 1, + "output_path": "test-output/pytest-test-patch-swarm.txt", + }, + { + "name": "focused regression", + "command": 'python3 -m pytest -q tests -k "patch_swarm or parallel_delivery or build or workset or factory or cli or registry or docs"', + "exit_code": 0 if command_status_from_file(regression_dir / "test-output" / "pytest-focused-regression.txt") == "pass" else 1, + "output_path": "test-output/pytest-focused-regression.txt", + }, + { + "name": "fixture e2e 100", + "command": "cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json", + "exit_code": 0 if gates["fixture-e2e-100"] == "pass" else 1, + "output_path": "test-output/patch-swarm-e2e-100.json", + }, + ] + return { + "schema_version": SCHEMA_REGRESSION_SUMMARY, + "run_id": run_id, + "status": status, + "commands": commands, + "gates": gates, + "changed_files": changed_files(), + "blockers": blockers, + "known_limitations": [ + "The Makefile target is optional and remains not-added when Makefile contains unrelated dirty work." + ] + if gates.get("makefile-target") == "not-added" + else [], + } + + +def validation_report(summary: dict[str, Any]) -> str: + lines = [ + "# Patch Swarm Regression Validation Report", + "", + f"- Run ID: `{summary['run_id']}`", + f"- Status: `{summary['status']}`", + "", + "## Gates", + "", + ] + for gate_id in REQUIRED_GATE_IDS: + lines.append(f"- `{gate_id}`: `{summary['gates'].get(gate_id, 'missing')}`") + if summary["blockers"]: + lines.extend(["", "## Blockers", ""]) + lines.extend(f"- `{item}`" for item in summary["blockers"]) + else: + lines.extend(["", "## Result", "", "Core Patch Swarm regression gates passed."]) + if summary["known_limitations"]: + lines.extend(["", "## Known Limitations", ""]) + lines.extend(f"- {item}" for item in summary["known_limitations"]) + return "\n".join(lines) + "\n" + + +def changed_files() -> list[str]: + import subprocess + + result = subprocess.run(["git", "status", "--short"], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return [line for line in result.stdout.splitlines() if line.strip()] + + +def docs_text() -> str: + paths = [ + ROOT / "docs" / "patch-swarm.md", + ROOT / "docs" / "parallel-ai-delivery-roadmap.md", + ROOT / "README.md", + ] + return "\n".join(read_text_if_exists(path) for path in paths) + + +def docs_checklist(run_id: str) -> dict[str, Any]: + combined = docs_text().lower() + sections = {} + blockers: list[str] = [] + for section, markers in DOC_SECTION_MARKERS.items(): + passed = any(marker.lower() in combined for marker in markers) + sections[section] = "pass" if passed else "fail" + if not passed: + blockers.append(section) + return { + "schema_version": SCHEMA_DOCS_CHECKLIST, + "run_id": run_id, + "docs_reviewed": [ + "docs/patch-swarm.md", + "docs/parallel-ai-delivery-roadmap.md", + "README.md", + ], + "required_sections": sections, + "blockers": blockers, + } + + +def operator_runbook_review(checklist: dict[str, Any]) -> str: + lines = [ + "# Patch Swarm Operator Runbook Review", + "", + f"- Run ID: `{checklist['run_id']}`", + f"- Status: `{'pass' if not checklist['blockers'] else 'fail'}`", + "", + "## Required Sections", + "", + ] + for section, status in checklist["required_sections"].items(): + lines.append(f"- `{section}`: `{status}`") + lines.extend( + [ + "", + "## Review Notes", + "", + "- The canonical operator doc is `docs/patch-swarm.md`.", + "- The runbook describes fixture-first execution, bounded worker batches, safe handoff, evidence, and adoption workflow.", + "- Live provider, worker, and Taskstream paths remain explicitly gated.", + ] + ) + return "\n".join(lines) + "\n" + + +def adoption_narrative(run_id: str) -> str: + return ( + "# Patch Swarm Adoption Narrative\n\n" + f"- Run ID: `{run_id}`\n\n" + "Patch Swarm scales AI-assisted delivery by turning one request into bounded work packets, " + "assigning owned paths, collecting patch bundles, validating them mechanically, and integrating " + "only receipt-backed winners. The model gives staff engineers and leads a reviewable control " + "plane: they can inspect the task graph, leases, bundles, integration plan, release candidate, " + "and durable evidence without trusting raw worker transcripts.\n\n" + "The adoption gate is deterministic. Teams start with the fixture E2E, add product lanes behind " + "the existing `parallel-delivery`, Build, Workset, Factory, and Taskstream surfaces, then enable " + "live provider or worker execution only when the local evidence path is boringly green.\n" + ) + + +def write_docs_validation_report(docs_dir: Path, checklist: dict[str, Any]) -> None: + lines = [ + "# Patch Swarm Docs Validation Report", + "", + f"- Run ID: `{checklist['run_id']}`", + f"- Status: `{'pass' if not checklist['blockers'] else 'fail'}`", + "", + "## Docs Reviewed", + "", + ] + lines.extend(f"- `{item}`" for item in checklist["docs_reviewed"]) + if checklist["blockers"]: + lines.extend(["", "## Missing Sections", ""]) + lines.extend(f"- `{item}`" for item in checklist["blockers"]) + else: + lines.extend(["", "## Result", "", "The operator runbook covers every required adoption section."]) + (docs_dir / "validation-report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_evidence(regression_dir: Path, docs_dir: Path, *, run_id: str) -> dict[str, Any]: + regression_dir.mkdir(parents=True, exist_ok=True) + docs_dir.mkdir(parents=True, exist_ok=True) + + matrix = build_regression_matrix(run_id) + write_json(regression_dir / "regression-matrix.json", matrix) + (regression_dir / "regression-matrix.md").write_text(regression_matrix_markdown(matrix), encoding="utf-8") + + summary = build_validation_summary(run_id, regression_dir) + write_json(regression_dir / "validation-summary.json", summary) + (regression_dir / "validation-report.md").write_text(validation_report(summary), encoding="utf-8") + + checklist = docs_checklist(run_id) + write_json(docs_dir / "docs-checklist.json", checklist) + (docs_dir / "operator-runbook-review.md").write_text(operator_runbook_review(checklist), encoding="utf-8") + (docs_dir / "adoption-narrative.md").write_text(adoption_narrative(run_id), encoding="utf-8") + write_docs_validation_report(docs_dir, checklist) + + return { + "ok": summary["status"] == "pass" and not checklist["blockers"], + "run_id": run_id, + "regression_dir": rel(regression_dir), + "docs_dir": rel(docs_dir), + "summary_status": summary["status"], + "docs_status": "pass" if not checklist["blockers"] else "fail", + "blockers": summary["blockers"] + [f"docs:{item}" for item in checklist["blockers"]], + } + + +def default_run_id() -> str: + return "callB-regression-docs-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Write Patch Swarm Call B regression/docs evidence.") + parser.add_argument("--regression-out", required=True, type=Path) + parser.add_argument("--docs-out", required=True, type=Path) + parser.add_argument("--run-id", default="") + parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + run_id = args.run_id or default_run_id() + result = write_evidence(args.regression_out, args.docs_out, run_id=run_id) + if args.json: + print(stable_json(result), end="") + else: + print(f"wrote Call B evidence: {result['regression_dir']} and {result['docs_dir']}") + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_call_c.py b/scripts/parallel_delivery_call_c.py new file mode 100644 index 0000000..72448e9 --- /dev/null +++ b/scripts/parallel_delivery_call_c.py @@ -0,0 +1,694 @@ +#!/usr/bin/env python3 +"""Final QA and release-candidate evidence for Patch Swarm. + +This helper turns captured final-gate command output into a durable evidence +index, dirty-work conflict report, final validation summary, and release +candidate packet. It does not run live providers, launch workers, apply +patches, or mutate Taskstream/Redmine state. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +RUNS_ROOT = ROOT / "workspace" / "runs" / "parallel-delivery" + +SCHEMA_EVIDENCE_INDEX = "patch-swarm-evidence-index.v1" +SCHEMA_FINAL_VALIDATION = "patch-swarm-final-validation.v1" +SCHEMA_RELEASE_CANDIDATE = "patch-swarm-release-candidate.v1" + +REQUIRED_GATES = [ + "tools_json", + "cento_tools", + "docs_parallel_delivery", + "parallel_delivery_validate_json", + "parallel_delivery_status_json", + "patch_swarm_e2e_100", + "patch_swarm_tests", + "safety_scan", + "docs_runbook", + "dirty_work_preserved", +] + +SECONDARY_GATES = [ + "cento_cli_json", + "focused_tests", + "patch_swarm_check", + "make_check", +] + +CALL_CATEGORIES = { + "product_spec": ["docs/patch-swarm.md", "patch-swarm-lifecycle", "patch-swarm-implementation-map", "patch-swarm-validation-matrix"], + "recon": ["recon/", "implementation-map"], + "schema": ["schema-fixture", "call-4-artifact-schema", "patch-swarm-artifacts"], + "planner": ["planner-fixture", "patch-swarm-planner"], + "leases": ["lease-fixture", "path-leases", "patch-swarm-leasing"], + "proreq_prompts": ["proreq-fixture", "prompt-bundle", "patch-swarm-proreq-prompts"], + "worker_packets": ["codex-packets-fixture", "worker-packets", "codex-packet"], + "patch_bundle_safety": ["patch-bundle-fixture", "patch_bundle", "patch-bundle"], + "integration_plan": ["integration-plan-fixture", "conflict-report", "integration-plan.json"], + "safe_apply_release_candidate": ["release-candidate-fixture", "release-candidate.json", "release-notes.md"], + "e2e_fixture": ["e2e-fixture"], + "taskstream_handoff": ["taskstream-fixture", "taskstream"], + "worker_status": ["worker-status-fixture", "worker-status"], + "console_status": ["console-fixture", "patch-swarm-console"], + "safety_hardening": ["safety-fixture", "safety-report", "safety-checklist"], + "regression_matrix": ["regression-fixture", "regression-matrix"], + "docs_runbook": ["docs-fixture", "operator-runbook-review", "adoption-narrative", "docs/patch-swarm.md"], +} + +PATCH_SWARM_PATHS = ( + "scripts/parallel_delivery", + "scripts/patch_swarm", + "tests/test_parallel_delivery", + "tests/parallel_delivery/", + "tests/test_patch_swarm.py", + "docs/patch-swarm", + "docs/parallel-delivery/", +) +UNRELATED_HINTS = ( + "industrial", + "darth lolipopus", + "assets/industrial-os", + "industrial-pet", + "cento_temp", + "temp-commands", + "workspace/logs", +) +CONSOLE_PATHS = ( + "scripts/agent_work_app.py", + "templates/agent-work-app/app.js", + "templates/agent-work-app/index.html", + "templates/agent-work-app/styles.css", +) + +REAL_SECRET_RE = re.compile(r"sk-[A-Za-z0-9]{20,}") +DANGEROUS_RE = re.compile(r"git reset --hard|git clean -fd|checkout --") +DOC_MARKERS = [ + "## Safe Mental Model", + "## Quickstart", + "## Full Fixture Demo", + "## ChatGPT Pro / ProReq Flow", + "## Codex Paste Flow", + "## Worker Packet Format", + "## Artifacts and Evidence", + "## Safety Rules", + "## Troubleshooting", + "## Extension Guide", + "## Adoption Narrative", +] + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json(payload: Any) -> str: + return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json(payload), encoding="utf-8") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") if path.exists() else "" + + +def read_json(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + return payload if isinstance(payload, dict) else None + + +def json_file_valid(path: Path) -> bool: + if not path.exists(): + return False + try: + json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return False + return True + + +def run_git(*args: str) -> str: + result = subprocess.run(["git", *args], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return result.stdout + + +def current_branch() -> str: + value = run_git("branch", "--show-current").strip() + return value or "unknown" + + +def current_head() -> str: + return run_git("rev-parse", "HEAD").strip() + + +def git_status_entries() -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for line in run_git("status", "--short").splitlines(): + if not line.strip(): + continue + state = line[:2] + path = line[3:] if len(line) > 3 else "" + entries.append({"state": state.strip() or "M", "path": path}) + return entries + + +def diff_for(path: str) -> str: + return run_git("diff", "--", path) + + +def classify_path(path: str, state: str) -> tuple[str, str, str]: + lowered = path.lower() + diff_text = diff_for(path).lower() if state != "??" else "" + combined = f"{lowered}\n{diff_text}" + + has_patch = "patch_swarm" in combined or "patch-swarm" in combined or any(hint in lowered for hint in PATCH_SWARM_PATHS) + has_unrelated = any(hint in combined for hint in UNRELATED_HINTS) + is_console = lowered in CONSOLE_PATHS + + if lowered.startswith("workspace/runs/parallel-delivery/"): + return "evidence-only", "preserve/generated evidence", "Generated Parallel Delivery evidence." + if is_console and has_patch and has_unrelated: + return "mixed/needs human review", "preserve unrelated hunks", "Console file contains Patch Swarm and unrelated UI/content signals." + if is_console and has_patch: + return "Patch Swarm Console/status", "safe to edit minimally", "Console/status Patch Swarm hunk." + if any(hint in lowered for hint in PATCH_SWARM_PATHS) or path in {"scripts/parallel_delivery_call_a.py", "scripts/parallel_delivery_call_b.py"}: + if "tests/" in lowered: + return "Patch Swarm tests", "safe to edit minimally", "Patch Swarm or Parallel Delivery test surface." + if "docs/" in lowered: + return "Patch Swarm docs/runbook", "safe to edit minimally", "Patch Swarm documentation surface." + return "Patch Swarm product code", "safe to edit minimally", "Parallel Delivery/Patch Swarm implementation surface." + if path == "docs/ai-self-improvement-log.md" and has_patch: + return "Patch Swarm docs/runbook", "safe to append only", "Append-only Cento self-improvement log with Patch Swarm entries." + if has_patch and has_unrelated: + return "mixed/needs human review", "preserve unrelated hunks", "File includes both Patch Swarm and unrelated work." + if has_patch: + return "Patch Swarm product code", "safe to edit minimally", "Patch Swarm hunk detected in diff." + if has_unrelated: + return "unrelated dirty work", "preserve", "Unrelated Industrial/temp/desktop work." + if state == "??": + return "unrelated dirty work", "preserve", "Untracked file outside Patch Swarm ownership." + return "unknown dirty work", "preserve", "No Patch Swarm ownership signal found." + + +def conflict_report(run_id: str) -> tuple[dict[str, Any], str]: + entries = git_status_entries() + table = [] + staged = [] + unstaged = [] + untracked = [] + blockers = [] + for entry in entries: + state = entry["state"] + path = entry["path"] + classification, action, notes = classify_path(path, state) + row = { + "path": path, + "git_state": state, + "classification": classification, + "action": action, + "notes": notes, + } + table.append(row) + if state == "??": + untracked.append(path) + else: + if state and state[0] != " ": + staged.append(path) + if len(state) > 1 and state[1] != " ": + unstaged.append(path) + if state in {"M", "A", "D"}: + unstaged.append(path) + if classification in {"mixed/needs human review", "unknown dirty work"}: + blockers.append(path) + + status = "partial" if blockers else "pass" + payload = { + "run_id": run_id, + "status": status, + "branch": current_branch(), + "head": current_head(), + "staged_files": sorted(set(staged)), + "unstaged_files": sorted(set(unstaged)), + "untracked_files": sorted(set(untracked)), + "files": table, + "blockers": blockers, + } + lines = [ + "# Owned Path Conflict Report", + "", + "## Summary", + f"- status: {status}", + f"- branch: {payload['branch']}", + f"- head: {payload['head']}", + f"- staged files: {len(payload['staged_files'])}", + f"- unstaged files: {len(payload['unstaged_files'])}", + f"- untracked files: {len(payload['untracked_files'])}", + "", + "## Classification Table", + "| Path | Git state | Classification | Action | Notes |", + "|---|---:|---|---|---|", + ] + for row in table: + lines.append( + f"| `{row['path']}` | `{row['git_state']}` | {row['classification']} | {row['action']} | {row['notes']} |" + ) + for heading, predicate in [ + ("Patch Swarm Owned Files", lambda r: r["classification"].startswith("Patch Swarm")), + ("Unrelated Dirty Work Preserved", lambda r: r["classification"] == "unrelated dirty work"), + ("Mixed Conflicts", lambda r: r["classification"] == "mixed/needs human review"), + ]: + lines.extend(["", f"## {heading}", ""]) + matched = [row for row in table if predicate(row)] + lines.extend(f"- `{row['path']}`: {row['notes']}" for row in matched) + if not matched: + lines.append("- None.") + lines.extend( + [ + "", + "## Safety Notes", + "No reset, checkout, clean, or broad stash was used by this final QA helper.", + "Unrelated dirty work is preserved and classified instead of rewritten.", + "", + "## Decision", + f"{status}. Blockers: {', '.join(blockers) if blockers else 'none'}.", + ] + ) + return payload, "\n".join(lines) + "\n" + + +def evidence_files() -> list[str]: + names = { + "validation-report.md", + "validation-summary.json", + "final-validation-summary.json", + "regression-matrix.json", + "regression-matrix.md", + "safety-report.md", + "safety-checklist.json", + "conflict-report.md", + "integration-plan.json", + "release-candidate.json", + "release-notes.md", + "docs-checklist.json", + "operator-runbook-review.md", + "adoption-narrative.md", + "split-plan.json", + "task-graph.json", + "path-leases.json", + "prompt-bundle.json", + "prompt-index.json", + "codex-packet-index.json", + "codex-packet-bundle.json", + "patch-bundle-validation.json", + "patch-bundle-report.pretty.json", + "taskstream-handoff-report.json", + "worker-status.json", + "worker-status-summary.json", + } + paths = [] + if RUNS_ROOT.exists(): + for path in RUNS_ROOT.rglob("*"): + if path.is_file() and path.name in names: + paths.append(rel(path)) + doc_paths = [ + "docs/patch-swarm.md", + "docs/patch-swarm-lifecycle.md", + "docs/patch-swarm-implementation-map.md", + "docs/patch-swarm-validation-matrix.md", + "docs/parallel-delivery/patch-swarm-artifacts.md", + ] + paths.extend(path for path in doc_paths if (ROOT / path).exists()) + return sorted(set(paths)) + + +def build_evidence_index(run_id: str) -> dict[str, Any]: + files = evidence_files() + calls = {} + for category, hints in CALL_CATEGORIES.items(): + matched = [path for path in files if any(hint.lower() in path.lower() for hint in hints)] + status = "found" if matched else "missing" + calls[category] = { + "status": status, + "paths": matched[-40:], + "truncated": max(0, len(matched) - 40), + } + blockers = [name for name, value in calls.items() if value["status"] == "missing"] + return { + "schema_version": SCHEMA_EVIDENCE_INDEX, + "run_id": run_id, + "generated_at": utc_now(), + "evidence_roots": ["workspace/runs/parallel-delivery", "docs/patch-swarm.md"], + "calls": calls, + "blockers": blockers, + } + + +def evidence_index_md(index: dict[str, Any]) -> str: + lines = [ + "# Patch Swarm Evidence Index", + "", + f"- Run ID: `{index['run_id']}`", + f"- Generated: `{index['generated_at']}`", + f"- Blockers: `{len(index['blockers'])}`", + "", + "| Area | Status | Evidence |", + "|---|---|---|", + ] + for name, value in index["calls"].items(): + paths = "
".join(f"`{path}`" for path in value["paths"][:8]) or "None" + if value.get("truncated"): + paths += f"
`... {value['truncated']} more`" + lines.append(f"| `{name}` | `{value['status']}` | {paths} |") + return "\n".join(lines) + "\n" + + +def output_file(final_dir: Path, name: str) -> Path: + return final_dir / "test-output" / name + + +def text_has_success(path: Path) -> bool: + text = read_text(path).lower() + if not text: + return False + if "failed" in text or "traceback" in text or "error:" in text: + return " passed" in text and " failed" not in text + return "passed" in text or "ok" in text or path.exists() + + +def json_gate(path: Path, predicate) -> str: + payload = read_json(path) + if payload is None: + return "fail" + return "pass" if predicate(payload) else "fail" + + +def docs_runbook_status() -> str: + text = read_text(ROOT / "docs" / "patch-swarm.md") + return "pass" if all(marker in text for marker in DOC_MARKERS) else "fail" + + +def safety_scan_status(path: Path) -> tuple[str, list[str]]: + findings = read_text(path).splitlines() + real_secret_findings = [ + line + for line in findings + if REAL_SECRET_RE.search(line) + and "sk-abcdefghijklmnopqrstuvwxyz" not in line + and "secret-and-dangerous-command-scan.txt:" not in line + ] + unsafe_generated = [] + for line in findings: + if not DANGEROUS_RE.search(line): + continue + lowered = line.lower() + safe_context = ( + "do not" in lowered + or "never" in lowered + or "disallowed" in lowered + or "forbidden" in lowered + or "reject" in lowered + or "scan" in lowered + or "dangerous" in lowered + or "acceptance" in lowered + or lowered.startswith("tests/") + or "secret-and-dangerous-command-scan.txt:" in lowered + ) + if not safe_context: + unsafe_generated.append(line) + blockers = real_secret_findings + unsafe_generated + return ("pass" if not blockers else "fail"), blockers[:20] + + +def dirty_work_preserved_status(final_dir: Path, conflict_payload: dict[str, Any]) -> str: + before = set(read_text(final_dir / "git-status-before.txt").splitlines()) + after = set(read_text(final_dir / "git-status-after.txt").splitlines()) + unrelated = { + line + for line in before + if any(hint in line.lower() for hint in UNRELATED_HINTS) + or any(path in line for path in ["Makefile", "README.md", "data/tools.json", "docs/tool-index.md"]) + } + if not unrelated: + return "pass" + return "pass" if unrelated <= after else "fail" + + +def build_final_summary(run_id: str, final_dir: Path, rc_dir: Path, conflict_payload: dict[str, Any]) -> dict[str, Any]: + safety_status, safety_blockers = safety_scan_status(output_file(final_dir, "secret-and-dangerous-command-scan.txt")) + required = { + "tools_json": "pass" if json_file_valid(ROOT / "data" / "tools.json") else "fail", + "cento_tools": "pass" if "parallel-delivery" in read_text(output_file(final_dir, "cento-tools.txt")) else "fail", + "docs_parallel_delivery": "pass" if "tool: parallel-delivery" in read_text(output_file(final_dir, "docs-parallel-delivery.txt")) else "fail", + "parallel_delivery_validate_json": json_gate( + output_file(final_dir, "parallel-delivery-validate.json"), + lambda p: p.get("status") in {"passed", "partial", "failed"} and "schema_version" in p, + ), + "parallel_delivery_status_json": json_gate( + output_file(final_dir, "parallel-delivery-status.json"), + lambda p: bool(p.get("status")) and "schema_version" in p, + ), + "patch_swarm_e2e_100": json_gate( + output_file(final_dir, "patch-swarm-e2e-100.json"), + lambda p: p.get("ok") is True + and p.get("candidate_count") == 100 + and p.get("max_parallel_agents") == 5 + and p.get("live_pro") is False, + ), + "patch_swarm_tests": "pass" if text_has_success(output_file(final_dir, "pytest-test-patch-swarm.txt")) else "fail", + "safety_scan": safety_status, + "docs_runbook": docs_runbook_status(), + "dirty_work_preserved": dirty_work_preserved_status(final_dir, conflict_payload), + } + patch_swarm_check_text = read_text(output_file(final_dir, "make-patch-swarm-check.txt")).lower() + if "not present" in patch_swarm_check_text: + patch_swarm_check_status = "not-present" + elif text_has_success(output_file(final_dir, "make-patch-swarm-check.txt")): + patch_swarm_check_status = "pass" + else: + patch_swarm_check_status = "fail" + secondary = { + "cento_cli_json": "pass" if json_file_valid(ROOT / "data" / "cento-cli.json") else "not-applicable", + "focused_tests": "pass" if text_has_success(output_file(final_dir, "pytest-focused-final.txt")) else "fail", + "patch_swarm_check": patch_swarm_check_status, + "make_check": "pass" if text_has_success(output_file(final_dir, "make-check.txt")) else "fail", + } + required_blockers = [gate for gate, status in required.items() if status != "pass"] + secondary_failures = [gate for gate, status in secondary.items() if status == "fail"] + status = "fail" if required_blockers else ("partial" if secondary_failures else "pass") + limitations = [] + if secondary.get("patch_swarm_check") == "not-present": + limitations.append("No dedicated make patch-swarm-check target is present; final QA used direct deterministic gates.") + if secondary_failures: + limitations.append("One or more secondary gates failed; inspect final QA command output.") + if conflict_payload.get("blockers"): + limitations.append("Dirty work includes mixed or unknown files requiring human review before broad packaging.") + return { + "schema_version": SCHEMA_FINAL_VALIDATION, + "run_id": run_id, + "status": status, + "branch": current_branch(), + "head": current_head(), + "required_gates": required, + "secondary_gates": secondary, + "changed_files": [entry["path"] for entry in conflict_payload["files"]], + "unrelated_dirty_files_preserved": [ + entry["path"] for entry in conflict_payload["files"] if entry["classification"] == "unrelated dirty work" + ], + "blockers": required_blockers + safety_blockers, + "known_limitations": limitations, + "evidence": { + "final_qa_dir": rel(final_dir), + "release_candidate_dir": rel(rc_dir), + }, + } + + +def final_report(summary: dict[str, Any]) -> str: + lines = [ + "# Patch Swarm Final Validation Report", + "", + f"- Run ID: `{summary['run_id']}`", + f"- Status: `{summary['status']}`", + f"- Branch: `{summary['branch']}`", + f"- Head: `{summary['head']}`", + "", + "## Required Gates", + "", + "| Gate | Status |", + "|---|---|", + ] + lines.extend(f"| `{gate}` | `{status}` |" for gate, status in summary["required_gates"].items()) + lines.extend(["", "## Secondary Gates", "", "| Gate | Status |", "|---|---|"]) + lines.extend(f"| `{gate}` | `{status}` |" for gate, status in summary["secondary_gates"].items()) + if summary["blockers"]: + lines.extend(["", "## Blockers", ""]) + lines.extend(f"- `{item}`" for item in summary["blockers"]) + else: + lines.extend(["", "## Result", "", "All core Patch Swarm release gates passed."]) + return "\n".join(lines) + "\n" + + +def release_candidate(summary: dict[str, Any]) -> dict[str, Any]: + return { + "schema_version": SCHEMA_RELEASE_CANDIDATE, + "run_id": summary["run_id"], + "status": summary["status"], + "branch": summary["branch"], + "head": summary["head"], + "summary": "Patch Swarm final QA result.", + "required_gates": summary["required_gates"], + "secondary_gates": summary["secondary_gates"], + "evidence": summary["evidence"], + "changed_files": summary["changed_files"], + "unrelated_dirty_files_preserved": summary["unrelated_dirty_files_preserved"], + "known_limitations": summary["known_limitations"], + "blockers": summary["blockers"], + } + + +def release_notes(summary: dict[str, Any]) -> str: + commands = [ + "python3 -m json.tool data/tools.json", + "cento tools", + "cento docs parallel-delivery", + "cento parallel-delivery validate --json", + "cento parallel-delivery status --json", + "cento parallel-delivery patch-swarm e2e --candidate-target 100 --max-parallel-agents 5 --fixture --json", + "python3 -m pytest -q tests/test_patch_swarm.py", + ] + lines = [ + "# Patch Swarm Release Candidate", + "", + "## Status", + summary["status"], + "", + "## Summary", + "Patch Swarm has a deterministic local release gate for planning, leases, worker packets, patch bundle validation, integration planning, release candidate evidence, status JSON, safety, regression, and operator docs.", + "", + "## Product Flow", + "one request -> split plan -> leases -> worker packets -> patch bundles -> validation -> integration plan -> release candidate -> evidence", + "", + "## Operator Commands", + "", + ] + lines.extend(f"- `{cmd}`" for cmd in commands) + lines.extend( + [ + "", + "## Evidence", + f"- Final QA: `{summary['evidence']['final_qa_dir']}`", + f"- Release candidate: `{summary['evidence']['release_candidate_dir']}`", + "", + "## Safety", + "Fixture and dry-run paths were used. No live Pro/API dispatch, real worker launch, Taskstream mutation, secret copy, reset, checkout, clean, or broad stash is required for the final gate.", + "", + "## Validation Results", + "", + "| Gate | Status |", + "|---|---|", + ] + ) + lines.extend(f"| `{gate}` | `{status}` |" for gate, status in summary["required_gates"].items()) + lines.extend(["", "## Known Limitations", ""]) + lines.extend(f"- {item}" for item in summary["known_limitations"] or ["None."]) + lines.extend(["", "## Next Actions", "", "- Review mixed/unrelated dirty work before any broad packaging or PR that includes non-Patch Swarm files."]) + return "\n".join(lines) + "\n" + + +def write_auxiliary_reports(final_dir: Path, summary: dict[str, Any]) -> None: + (final_dir / "known-limitations.md").write_text( + "# Known Limitations\n\n" + "\n".join(f"- {item}" for item in summary["known_limitations"] or ["None."]) + "\n", + encoding="utf-8", + ) + (final_dir / "next-actions.md").write_text( + "# Next Actions\n\n" + "- Use the release candidate packet as the final Patch Swarm gate.\n" + "- Keep live Pro/API, workers, and Taskstream apply paths behind explicit opt-in flags.\n" + "- Review unrelated Industrial/temp dirty work separately before packaging it with Patch Swarm.\n", + encoding="utf-8", + ) + (final_dir / "changed-files.txt").write_text("\n".join(summary["changed_files"]) + "\n", encoding="utf-8") + + +def write_final_evidence(final_dir: Path, rc_dir: Path, *, run_id: str) -> dict[str, Any]: + final_dir.mkdir(parents=True, exist_ok=True) + rc_dir.mkdir(parents=True, exist_ok=True) + + conflict_payload, conflict_md = conflict_report(run_id) + write_json(final_dir / "owned-path-conflict-report.json", conflict_payload) + (final_dir / "owned-path-conflict-report.md").write_text(conflict_md, encoding="utf-8") + + index = build_evidence_index(run_id) + write_json(final_dir / "evidence-index.json", index) + (final_dir / "evidence-index.md").write_text(evidence_index_md(index), encoding="utf-8") + + summary = build_final_summary(run_id, final_dir, rc_dir, conflict_payload) + write_json(final_dir / "final-validation-summary.json", summary) + (final_dir / "final-validation-report.md").write_text(final_report(summary), encoding="utf-8") + write_auxiliary_reports(final_dir, summary) + + candidate = release_candidate(summary) + write_json(rc_dir / "release-candidate.json", candidate) + (rc_dir / "release-notes.md").write_text(release_notes(summary), encoding="utf-8") + + return { + "ok": summary["status"] == "pass", + "run_id": run_id, + "status": summary["status"], + "final_qa_dir": rel(final_dir), + "release_candidate_dir": rel(rc_dir), + "blockers": summary["blockers"], + "known_limitations": summary["known_limitations"], + } + + +def default_run_id() -> str: + return "callC-final-qa-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Write Patch Swarm final QA and release candidate evidence.") + parser.add_argument("--final-out", required=True, type=Path) + parser.add_argument("--release-candidate-out", required=True, type=Path) + parser.add_argument("--run-id", default="") + parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + run_id = args.run_id or default_run_id() + result = write_final_evidence(args.final_out, args.release_candidate_out, run_id=run_id) + if args.json: + print(stable_json(result), end="") + else: + print(f"wrote final QA evidence: {result['final_qa_dir']} and {result['release_candidate_dir']}") + return 0 if result["status"] in {"pass", "partial"} else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_codex_packets.py b/scripts/parallel_delivery_codex_packets.py new file mode 100644 index 0000000..2eb5571 --- /dev/null +++ b/scripts/parallel_delivery_codex_packets.py @@ -0,0 +1,1299 @@ +#!/usr/bin/env python3 +"""Local Patch Swarm Codex worker packet generator. + +This helper emits paste-ready Codex worker packets from split-plan, task-graph, +and path-lease artifacts. It is local-only: it does not dispatch workers, call +model APIs, apply patches, or mutate Taskstream/Redmine state. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shlex +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +CURRENT_SCHEMA_VERSION = 1 +DEFAULT_PACKET_COUNT = 10 +PRODUCER = "cento.parallel-delivery.codex-packets" + +SUPPORTED_LANES = { + "builder", + "validator", + "docs-evidence", + "coordinator", + "integrator", + "human-handoff", +} +LANE_ORDER = ["builder", "validator", "docs-evidence", "coordinator", "integrator"] +LANE_PROFILES = { + "builder": ("python-builder", "medium"), + "validator": ("test-writer", "low"), + "docs-evidence": ("docs-evidence-writer", "low"), + "coordinator": ("factory-planner", "medium"), + "integrator": ("safe-integrator", "high"), + "human-handoff": ("human-operator", "human"), +} + +REQUIRED_PACKET_SECTIONS = [ + "## Thread Title", + "## Task ID", + "## Mission", + "## Discovery Commands", + "## Owned Write Paths", + "## Read-Only Paths", + "## Prohibited Paths", + "## Implementation Steps", + "## Expected Files Changed", + "## Tests And Validation", + "## Evidence Path", + "## Patch Bundle Output Instructions", + "## Handoff Note Format", + "## Failure / Blocker Protocol", + "## Safety Rules", + "## Acceptance Criteria", +] + +SECRET_PROTECTED_PATTERNS = [ + ".env", + ".env.", + ".env.mcp", + "OPENAI_API_KEY", + "sk-", + "api_key", + "token=", + "password=", + "credential", +] + +BASE_PROHIBITED_PATHS = [ + ".env", + ".env.*", + ".env.mcp", + ".git/**", + "**/*.pem", + "**/*.key", + "**/*secret*", + "**/*token*", + "**/*credential*", + "paths outside owned write paths", + "read-only paths", + "other tasks' owned paths", +] + +SECRET_VALUE_REGEXES = [ + re.compile(r"OPENAI_API_KEY\s*=\s*\S+", re.IGNORECASE), + re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"), + re.compile(r"\b(api_key|token|password)\s*=\s*[A-Za-z0-9_./+=-]{8,}\b", re.IGNORECASE), +] + + +class CodexPacketError(Exception): + """Raised when worker packet generation or validation fails.""" + + +@dataclass(frozen=True) +class CodexPacketRequest: + run_id: str + run_dir: Path + count: int | None = None + split_plan_path: Path | None = None + task_graph_path: Path | None = None + path_leases_path: Path | None = None + out_dir: Path | None = None + fixed_timestamp: str | None = None + + +@dataclass(frozen=True) +class CodexPacketSpec: + packet_id: str + task_id: str + title: str + lane: str + risk_tier: str + worker_profile: str + owned_write_paths: list[str] + read_only_paths: list[str] + prohibited_paths: list[str] + validation_commands: list[str] + evidence_path: str + patch_bundle_path: str + copy_order: int + requires_manual_review: bool + mission: str + expected_files_changed: list[str] + implementation_steps: list[str] + acceptance_criteria: list[str] + dependencies: list[str] + risk_notes: list[str] + + +@dataclass(frozen=True) +class CodexPacketResult: + run_id: str + run_dir: Path + packet_count: int + bundle_path: Path + index_path: Path + packets: list[dict[str, Any]] + warnings: list[str] + errors: list[str] + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json_dumps(payload: Any) -> str: + """Return deterministic JSON with sorted keys, two-space indent, and trailing newline.""" + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + """Write deterministic JSON artifact.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json_dumps(payload), encoding="utf-8") + + +def sha256_file(path: Path) -> str: + """Return sha256 digest for packet index validation.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def resolve_path(path: Path) -> Path: + return path if path.is_absolute() else ROOT / path + + +def run_dir_text(run_dir: Path) -> str: + return rel(run_dir) + + +def safe_read_json(path: Path) -> dict[str, Any]: + """Read JSON artifact safely and fail clearly.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise CodexPacketError(f"required JSON artifact missing: {rel(path)}") from exc + except json.JSONDecodeError as exc: + raise CodexPacketError(f"invalid JSON in {rel(path)}: {exc}") from exc + if not isinstance(payload, dict): + raise CodexPacketError(f"expected JSON object in {rel(path)}") + return payload + + +def normalize_relative_path(value: str) -> str: + path = str(value).replace("\\", "/").strip().strip("/") + if not path: + raise CodexPacketError("path must not be empty") + if path.startswith("/"): + raise CodexPacketError(f"absolute paths are not allowed: {value}") + if ".." in path.split("/"): + raise CodexPacketError(f"parent traversal is not allowed: {value}") + return path + + +def normalize_path_list(paths: Any) -> list[str]: + if not isinstance(paths, list): + return [] + normalized: list[str] = [] + for item in paths: + if not isinstance(item, str): + continue + normalized.append(normalize_relative_path(item)) + return sorted(dict.fromkeys(normalized)) + + +def unique_text(items: list[str]) -> list[str]: + return list(dict.fromkeys(item for item in items if item)) + + +def text_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def redact_secret_like_text(text: str) -> tuple[str, list[str]]: + """Redact obvious secret-like strings from packet content.""" + warnings: list[str] = [] + redacted = text + for regex in SECRET_VALUE_REGEXES: + if regex.search(redacted): + warnings.append(f"redacted secret-like pattern: {regex.pattern}") + redacted = regex.sub("[REDACTED_SECRET_LIKE_VALUE]", redacted) + return redacted, warnings + + +def validate_packet_count(count: int | None, task_count: int) -> int: + """Resolve requested packet count; real runs emit one packet for every task.""" + if task_count < 1: + raise CodexPacketError("task graph has no tasks") + if count is None: + return task_count + if count < task_count: + raise CodexPacketError(f"requested count {count} is less than task count {task_count}") + return task_count + + +def load_worker_context(request: CodexPacketRequest) -> dict[str, Any]: + """Load split plan, task graph, path leases, and derive task/lease context.""" + run_dir = resolve_path(request.run_dir) + split_path = resolve_path(request.split_plan_path) if request.split_plan_path else run_dir / "split-plan.json" + graph_path = resolve_path(request.task_graph_path) if request.task_graph_path else run_dir / "task-graph.json" + leases_path = resolve_path(request.path_leases_path) if request.path_leases_path else run_dir / "path-leases.json" + request_path = run_dir / "request.md" + + if not leases_path.exists(): + raise CodexPacketError(f"path-leases.json is required for real packet generation: {rel(leases_path)}") + + split_plan = safe_read_json(split_path) + task_graph = safe_read_json(graph_path) + path_leases = safe_read_json(leases_path) + + tasks = [task for task in split_plan.get("tasks", []) if isinstance(task, dict)] + if not tasks and isinstance(task_graph.get("nodes"), list): + tasks = [node for node in task_graph["nodes"] if isinstance(node, dict)] + resolved_count = validate_packet_count(request.count, len(tasks)) + tasks = tasks[:resolved_count] + + run_id = request.run_id or str(split_plan.get("run_id") or path_leases.get("run_id") or run_dir.name) + request_text = request_path.read_text(encoding="utf-8") if request_path.exists() else "" + return { + "run_id": run_id, + "run_dir": run_dir, + "run_dir_text": run_dir_text(run_dir), + "request_text": request_text, + "split_plan": split_plan, + "task_graph": task_graph, + "path_leases": path_leases, + "tasks": tasks, + "timestamp": request.fixed_timestamp or utc_now(), + } + + +def path_overlaps(left: str, right: str) -> bool: + left = left.rstrip("/") + right = right.rstrip("/") + return left == right or left.startswith(right + "/") or right.startswith(left + "/") + + +def _lease_by_task(path_leases: dict[str, Any]) -> dict[str, dict[str, Any]]: + leases: dict[str, dict[str, Any]] = {} + for item in path_leases.get("leases", []): + if not isinstance(item, dict): + continue + task_id = str(item.get("task_id") or "") + if task_id: + leases[task_id] = item + return leases + + +def _task_id(task: dict[str, Any], index: int) -> str: + return str(task.get("task_id") or task.get("id") or f"task-{index:04d}") + + +def _task_lane(task: dict[str, Any]) -> str: + lane = str(task.get("lane") or "").strip() or "builder" + if bool(task.get("human_handoff")): + lane = "human-handoff" + if lane not in SUPPORTED_LANES: + raise CodexPacketError(f"unsupported task lane: {lane}") + return lane + + +def prohibited_paths_for_task(task: dict[str, Any], lease: dict[str, Any], all_leases: list[dict[str, Any]]) -> list[str]: + """Return protected paths, read-only paths, and other tasks' owned paths.""" + task_id = str(task.get("task_id") or task.get("id") or lease.get("task_id") or "") + owned = set(normalize_path_list(lease.get("owned_paths", []))) + read_only = normalize_path_list(lease.get("read_only_paths", [])) + guarded = normalize_path_list(lease.get("guarded_paths", [])) + protected = normalize_path_list(lease.get("protected_paths", [])) + other_owned: list[str] = [] + for other in all_leases: + if not isinstance(other, dict) or str(other.get("task_id") or "") == task_id: + continue + other_owned.extend(normalize_path_list(other.get("owned_paths", []))) + prohibited = [*BASE_PROHIBITED_PATHS, *read_only, *guarded, *protected] + prohibited.extend(path for path in other_owned if path not in owned) + return unique_text(prohibited) + + +def lane_guidance(lane: str) -> str: + """Return lane-specific implementation guidance.""" + return { + "builder": ( + "Keep implementation small and bounded. Change the minimal set of owned files, " + "run the listed validation commands, and produce the patch bundle artifacts." + ), + "validator": ( + "Focus on tests, fixtures, validation harnesses, negative cases, and clear evidence " + "showing failing and passing checks." + ), + "docs-evidence": ( + "Focus on docs, runbooks, evidence summaries, and operator-facing wording. Do not " + "change source code unless that source path is explicitly leased." + ), + "coordinator": ( + "Coordinate manifest, schema, CLI routing, docs, and registry consistency without broad rewrites." + ), + "integrator": ( + "Plan or validate integration only. Do not apply patches unless the owned lease explicitly " + "allows it and all ordering/evidence checks are satisfied." + ), + "human-handoff": ( + "This task is not safe for automated Codex implementation. Do not edit repo files. " + "Produce a handoff note with decision points, required human action, and evidence." + ), + }[lane] + + +def build_packet_specs(context: dict[str, Any], *, count: int | None = None) -> list[CodexPacketSpec]: + """Build deterministic packet specs for every task.""" + tasks = [task for task in context["tasks"] if isinstance(task, dict)] + if count is not None: + validate_packet_count(count, len(tasks)) + leases = [lease for lease in context["path_leases"].get("leases", []) if isinstance(lease, dict)] + leases_by_task = _lease_by_task(context["path_leases"]) + specs: list[CodexPacketSpec] = [] + for index, task in enumerate(tasks, start=1): + task_id = _task_id(task, index) + lane = _task_lane(task) + human = lane == "human-handoff" or bool(task.get("human_handoff")) + lease = leases_by_task.get(task_id, {}) + if not lease and not human: + raise CodexPacketError(f"task {task_id} has no path lease") + + profile, risk = LANE_PROFILES[lane] + worker_profile = str(task.get("worker_profile") or lease.get("worker_profile") or profile) + risk_tier = str(task.get("risk_tier") or lease.get("risk_tier") or risk) + owned = [] if human else normalize_path_list(lease.get("owned_paths", [])) + read_only = normalize_path_list(lease.get("read_only_paths", task.get("read_only_paths", []))) + prohibited = prohibited_paths_for_task({"task_id": task_id}, lease, leases) if lease else unique_text(BASE_PROHIBITED_PATHS + read_only) + validation_commands = text_list(task.get("validation_commands")) or [ + "python3 -m json.tool data/tools.json >/dev/null", + "python3 -m json.tool data/cento-cli.json >/dev/null", + ] + dependencies = text_list(task.get("dependencies") or task.get("depends_on") or lease.get("dependency_gates")) + dirty_owned = normalize_path_list(lease.get("dirty_owned_paths", [])) + guarded = normalize_path_list(lease.get("guarded_paths", [])) + protected = normalize_path_list(lease.get("protected_paths", [])) + risk_notes = [] + if dirty_owned: + risk_notes.append("Dirty owned paths are flagged in the lease: " + ", ".join(dirty_owned)) + if guarded: + risk_notes.append("Guarded paths require blocker handling unless explicitly owned: " + ", ".join(guarded)) + if protected: + risk_notes.append("Protected paths must not be edited: " + ", ".join(protected)) + if dependencies: + risk_notes.append("Dependency gates: " + ", ".join(dependencies)) + + expected = [] if human else (text_list(task.get("expected_artifacts")) or owned) + steps = [ + "Run the discovery commands before editing anything.", + "Inspect only the listed read-only context and owned paths needed for the task.", + lane_guidance(lane), + "Make the smallest safe change inside Owned Write Paths, or write a blocker handoff if that is impossible.", + "Run validation commands or record why a command could not run.", + "Write the patch bundle, diff, handoff note, and evidence files before reporting done.", + ] + if human: + steps = [ + "Run discovery and inspect the task context only.", + "Do not edit repo files.", + "Write a handoff note with decision points, required human action, evidence, blockers, and suggested next action.", + ] + acceptance = text_list(task.get("acceptance_contract")) or [ + "Validation passes or exact failure evidence is recorded.", + "Evidence is written under the task evidence path.", + "Patch bundle and handoff artifacts are complete.", + ] + specs.append( + CodexPacketSpec( + packet_id=f"packet-{task_id}", + task_id=task_id, + title=str(task.get("title") or task.get("summary") or task_id), + lane=lane, + risk_tier=risk_tier, + worker_profile=worker_profile, + owned_write_paths=owned, + read_only_paths=read_only, + prohibited_paths=prohibited, + validation_commands=validation_commands, + evidence_path=f"workers/{task_id}/evidence/", + patch_bundle_path=f"patch-bundles/{task_id}.patch-bundle.json", + copy_order=index, + requires_manual_review=bool(human or lease.get("requires_manual_review")), + mission=str(task.get("summary") or task.get("story") or task.get("title") or task_id), + expected_files_changed=expected, + implementation_steps=steps, + acceptance_criteria=acceptance, + dependencies=dependencies, + risk_notes=risk_notes, + ) + ) + return specs + + +def md_list(items: list[str], *, code: bool = True) -> str: + if not items: + return "- None" + if code: + return "\n".join(f"- `{item}`" for item in items) + return "\n".join(f"- {item}" for item in items) + + +def shell_quote(path: str) -> str: + return shlex.quote(path) + + +def patch_bundle_instructions(run_id: str, task_id: str) -> str: + """Return patch bundle output schema and instructions.""" + schema = { + "schema_version": 1, + "artifact_type": "patch-bundle", + "run_id": run_id, + "task_id": task_id, + "base_ref": "string", + "worker_id": "codex", + "claimed_paths": [], + "changed_paths": [], + "diff_path": f"patch-bundles/{task_id}.diff", + "summary": "string", + "tests_run": [], + "evidence_files": [], + "handoff_note": f"workers/{task_id}/handoff.md", + "risks": [], + "requires_manual_review": False, + } + return ( + f"Write these artifacts under the packet run directory:\n\n" + f"- `workers/{task_id}/handoff.md`\n" + f"- `workers/{task_id}/evidence/`\n" + f"- `patch-bundles/{task_id}.patch-bundle.json`\n" + f"- `patch-bundles/{task_id}.diff`\n\n" + "The patch bundle must include summary, files changed, tests run, evidence files, risks, blockers, " + "and the manual review flag. Do not fabricate test results. If validation cannot run, record why in " + "the handoff note.\n\n" + "```json\n" + f"{stable_json_dumps(schema).rstrip()}\n" + "```" + ) + + +def handoff_note_format(task_id: str) -> str: + """Return required handoff note template.""" + return ( + "```markdown\n" + "# Codex Worker Handoff\n\n" + "## Task ID\n\n" + f"{task_id}\n\n" + "## Status\n\n" + "completed | blocked | failed | partial\n\n" + "## Summary\n\n" + "## Files Changed\n\n" + "## Validation Run\n\n" + "## Evidence Files\n\n" + "## Blockers\n\n" + "## Risks\n\n" + "## Suggested Next Action\n" + "```" + ) + + +def failure_protocol_text(task_id: str) -> str: + return ( + f"Stop and write `workers/{task_id}/handoff.md` if:\n\n" + "- required edits are outside owned paths\n" + "- dirty work would be overwritten\n" + "- validation requires missing secrets or external services\n" + "- task requires Taskstream/Redmine direct DB writes\n" + "- acceptance criteria are contradictory\n" + "- dependency artifacts are missing\n" + "- protected paths need changes" + ) + + +def safety_rules_text() -> str: + """Return safety rules for every Codex packet.""" + return ( + "- Do not edit files outside Owned Write Paths.\n" + "- Read-only paths may be inspected but not modified.\n" + "- If a required change appears outside the lease, stop and write a blocker note.\n" + "- Preserve dirty work. Do not reset, checkout, clean, stash, or overwrite unrelated changes.\n" + "- Never run git reset, git checkout, git clean, stash, or broad overwrite commands.\n" + "- Do not copy secrets or inspect local secret files.\n" + "- Never copy secrets or local environment values.\n" + "- Never inspect `.env.mcp` or local secret files.\n" + "- Do not mutate Taskstream/Redmine/story state through direct database writes.\n" + "- Never mutate Taskstream/Redmine/story state through direct database writes.\n" + "- Do not mark done unless validation passes and evidence is written.\n" + "- If you need a path outside the lease, stop and write a blocker handoff note." + ) + + +def discovery_commands(context: dict[str, Any], spec: CodexPacketSpec) -> str: + run_dir = context["run_dir_text"] + commands = [ + "cd /home/alice/projects/cento", + "git status --short --branch", + "git status --porcelain=v1", + f"test -f {shell_quote(run_dir + '/path-leases.json')} && python3 -m json.tool {shell_quote(run_dir + '/path-leases.json')} >/dev/null", + f"test -f {shell_quote(run_dir + '/split-plan.json')} && python3 -m json.tool {shell_quote(run_dir + '/split-plan.json')} >/dev/null", + f"test -f {shell_quote(run_dir + '/task-graph.json')} && python3 -m json.tool {shell_quote(run_dir + '/task-graph.json')} >/dev/null", + ] + for path in [*spec.owned_write_paths, *spec.read_only_paths]: + commands.append(f"test -e {shell_quote(path)} || true") + return "```bash\n" + "\n".join(commands) + "\n```" + + +def render_codex_packet(context: dict[str, Any], spec: CodexPacketSpec) -> str: + """Render one Codex worker packet Markdown file.""" + metadata = { + "artifact_type": "codex-worker-packet", + "run_id": context["run_id"], + "schema_version": CURRENT_SCHEMA_VERSION, + "task_id": spec.task_id, + } + dirty_work = ( + "Preserve dirty work. Before editing, inspect status. Never overwrite unrelated hunks; " + "if dirty owned paths would be overwritten, stop and write a blocker handoff note." + ) + human_note = "" + if spec.lane == "human-handoff": + human_note = ( + "\nThis task is not safe for automated Codex implementation. Do not edit repo files. " + "Produce a handoff note with decision points, required human action, and evidence.\n" + ) + packet = f"""# Codex Worker Packet + + + +You are Codex working in the Cento repo. You must run discovery first, preserve dirty work, edit only owned write paths, validate deterministically, and leave a patch bundle plus evidence. +{human_note} +## Thread Title + +Patch Swarm {spec.task_id} - {spec.title} + +## Task ID + +`{spec.task_id}` + +## Mission + +Lane: `{spec.lane}` +Worker profile: `{spec.worker_profile}` +Risk tier: `{spec.risk_tier}` + +{spec.mission} + +## Discovery Commands + +{discovery_commands(context, spec)} + +## Owned Write Paths + +{md_list(spec.owned_write_paths)} + +## Read-Only Paths + +{md_list(spec.read_only_paths)} + +Read-only paths may be inspected but not modified. + +## Prohibited Paths + +{md_list(spec.prohibited_paths)} + +Do not edit files outside Owned Write Paths. If a required change appears outside the lease, stop and write a blocker note. + +## Implementation Steps + +{md_list(spec.implementation_steps, code=False)} + +## Expected Files Changed + +{md_list(spec.expected_files_changed)} + +## Tests And Validation + +{md_list(spec.validation_commands)} + +Do not fabricate test results. If validation cannot run, record why in the handoff note. + +## Evidence Path + +`{spec.evidence_path}` + +## Patch Bundle Output Instructions + +{patch_bundle_instructions(context["run_id"], spec.task_id)} + +## Handoff Note Format + +{handoff_note_format(spec.task_id)} + +## Failure / Blocker Protocol + +{failure_protocol_text(spec.task_id)} + +## Safety Rules + +{safety_rules_text()} + +## Acceptance Criteria + +{md_list(spec.acceptance_criteria, code=False)} + +## Run Context + +- Run ID: `{context["run_id"]}` +- Run directory: `{context["run_dir_text"]}` +- Packet ID: `{spec.packet_id}` +- Copy order: `{spec.copy_order}` + +## Dependencies + +{md_list(spec.dependencies)} + +## Lane Guidance + +{lane_guidance(spec.lane)} + +## Risk Notes + +{md_list(spec.risk_notes, code=False)} + +## Dirty Work Handling + +{dirty_work} + +## Output Checklist + +- Discovery commands run and status reviewed. +- Only Owned Write Paths changed. +- Validation commands run or exact blockers recorded. +- Evidence written under `{spec.evidence_path}`. +- Patch bundle JSON and diff written under `patch-bundles/`. +- Handoff note written to `workers/{spec.task_id}/handoff.md`. +""" + redacted, _warnings = redact_secret_like_text(packet) + return redacted + + +def packet_dir_for_request(request: CodexPacketRequest, run_id: str) -> Path: + if request.out_dir: + return resolve_path(request.out_dir) + fixture_like = run_id.endswith("fixture") or request.run_dir.name.endswith("fixture") + return resolve_path(request.run_dir) / ("packets" if fixture_like else "codex-packets") + + +def packet_entry(path: Path, run_dir: Path, spec: CodexPacketSpec) -> dict[str, Any]: + return { + "copy_order": spec.copy_order, + "evidence_path": spec.evidence_path, + "lane": spec.lane, + "owned_write_paths": spec.owned_write_paths, + "packet_id": spec.packet_id, + "patch_bundle_path": spec.patch_bundle_path, + "path": path.relative_to(run_dir).as_posix(), + "prohibited_paths": spec.prohibited_paths, + "read_only_paths": spec.read_only_paths, + "requires_manual_review": spec.requires_manual_review, + "risk_tier": spec.risk_tier, + "sha256": sha256_file(path), + "task_id": spec.task_id, + "title": spec.title, + "validation_commands": spec.validation_commands, + "worker_profile": spec.worker_profile, + } + + +def write_packet_index_md(path: Path, bundle: dict[str, Any]) -> None: + """Write human-readable packet index.""" + packets = [item for item in bundle.get("packets", []) if isinstance(item, dict)] + lane_counts: dict[str, int] = {} + for item in packets: + lane = str(item.get("lane") or "unknown") + lane_counts[lane] = lane_counts.get(lane, 0) + 1 + lines = [ + "# Patch Swarm Codex Worker Packet Index", + "", + "## How to Use", + "", + "Copy one packet into one Codex thread. Do not dispatch these packets automatically from this generator.", + "", + "## Packet Order", + "", + ] + lines.extend(f"{item.get('copy_order')}. `{item.get('task_id')}` - `{item.get('path')}` - {item.get('lane')}" for item in packets) + lines.extend(["", "## Lane Summary", ""]) + lines.extend(f"- `{lane}`: {count}" for lane, count in sorted(lane_counts.items())) + lines.extend(["", "## Path Ownership Summary", ""]) + for item in packets: + lines.append(f"- `{item.get('task_id')}` owns: {', '.join(f'`{p}`' for p in item.get('owned_write_paths', [])) or 'None'}") + lines.extend(["", "## Validation Summary", ""]) + for item in packets: + lines.append(f"- `{item.get('task_id')}`: {len(item.get('validation_commands', []))} command(s)") + lines.extend( + [ + "", + "## Handoff Protocol", + "", + "Blocked, failed, partial, and completed workers write `workers//handoff.md`.", + "", + "## Evidence", + "", + ] + ) + lines.extend(f"- `{item.get('task_id')}` evidence: `{item.get('evidence_path')}`" for item in packets) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_readmes(run_dir: Path, run_id: str) -> None: + (run_dir / "patch-bundles").mkdir(parents=True, exist_ok=True) + (run_dir / "handoffs").mkdir(parents=True, exist_ok=True) + (run_dir / "workers").mkdir(parents=True, exist_ok=True) + (run_dir / "patch-bundles" / "README.md").write_text( + f"# Patch Bundles\n\nCodex workers for `{run_id}` write `.patch-bundle.json` and `.diff` here.\n", + encoding="utf-8", + ) + (run_dir / "handoffs" / "README.md").write_text( + f"# Handoffs\n\nUse worker handoff notes for blocked or human-review tasks in `{run_id}`.\n", + encoding="utf-8", + ) + + +def write_packet_bundle(request: CodexPacketRequest) -> CodexPacketResult: + """Write packet Markdown files, bundle metadata, index JSON/MD, reports, and start-here.""" + context = load_worker_context(request) + run_dir = context["run_dir"] + run_id = context["run_id"] + timestamp = context["timestamp"] + packet_dir = packet_dir_for_request(request, run_id) + packet_dir.mkdir(parents=True, exist_ok=True) + write_readmes(run_dir, run_id) + + specs = build_packet_specs(context, count=request.count) + packet_entries: list[dict[str, Any]] = [] + warnings: list[str] = [] + for spec in specs: + packet_path = packet_dir / f"{spec.task_id}-codex-packet.md" + text = render_codex_packet(context, spec) + redacted, redact_warnings = redact_secret_like_text(text) + warnings.extend(f"{spec.task_id}: {warning}" for warning in redact_warnings) + packet_path.write_text(redacted, encoding="utf-8") + packet_entries.append(packet_entry(packet_path, run_dir, spec)) + + lanes = [lane for lane in LANE_ORDER if any(item["lane"] == lane for item in packet_entries)] + if any(item["lane"] == "human-handoff" for item in packet_entries): + lanes.append("human-handoff") + + bundle = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "codex-packet-bundle", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": { + "producer": PRODUCER, + "command": "patch-swarm worker-packets", + "source": "split-plan/task-graph/path-leases", + "notes": [], + }, + "source_artifacts": { + "request": "request.md", + "split_plan": "split-plan.json", + "task_graph": "task-graph.json", + "path_leases": "path-leases.json", + }, + "packet_count": len(packet_entries), + "lanes": lanes, + "policy": { + "local_only": True, + "no_api_calls": True, + "no_secrets": True, + "owned_paths_required": True, + "workset_compatible": True, + "patch_bundle_required": True, + "evidence_required": True, + }, + "packets": packet_entries, + "warnings": warnings, + "evidence_pointers": ["packet-validation.json", "packet-validation-report.md"], + } + index = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "codex-packet-index", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "packet_count": len(packet_entries), + "packets": packet_entries, + } + bundle_path = run_dir / "codex-packet-bundle.json" + index_path = run_dir / "codex-packet-index.json" + write_json(bundle_path, bundle) + write_json(index_path, index) + write_packet_index_md(run_dir / "codex-packet-index.md", bundle) + write_start_here(run_dir, bundle) + validation = validate_packet_bundle(run_dir) + write_json(run_dir / "packet-validation.json", validation) + write_validation_report(run_dir / "packet-validation-report.md", validation) + return CodexPacketResult( + run_id=run_id, + run_dir=run_dir, + packet_count=len(packet_entries), + bundle_path=bundle_path, + index_path=index_path, + packets=packet_entries, + warnings=warnings, + errors=validation.get("errors", []), + ) + + +def write_start_here(run_dir: Path, bundle: dict[str, Any]) -> None: + lines = [ + f"# Patch Swarm Codex Packet Run: {bundle['run_id']}", + "", + "## What This Is", + "", + "A local-only bundle of paste-ready Codex worker packets. It does not dispatch workers.", + "", + "## Artifact Index", + "", + "- `codex-packet-bundle.json`", + "- `codex-packet-index.json`", + "- `codex-packet-index.md`", + "- `packets/` or `codex-packets/`", + "- `patch-bundles/`", + "- `workers/`", + "", + "## Validation Result", + "", + "`packet-validation.json` records deterministic packet checks.", + "", + "## Operator Next Step", + "", + "Open `codex-packet-index.md`, copy one packet into one Codex thread, and collect the worker patch bundle afterward.", + ] + (run_dir / "start-here.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def validate_packet_file(path: Path) -> list[str]: + """Validate required packet sections and secret-safety constraints.""" + errors: list[str] = [] + text = path.read_text(encoding="utf-8") + if not text.startswith("# Codex Worker Packet"): + errors.append(f"{rel(path)} must start with # Codex Worker Packet") + for heading in REQUIRED_PACKET_SECTIONS: + if heading not in text: + errors.append(f"{rel(path)} missing {heading}") + required_phrases = [ + "Do not edit files outside Owned Write Paths.", + "Read-only paths may be inspected but not modified.", + "If a required change appears outside the lease, stop and write a blocker note.", + "Preserve dirty work. Do not reset, checkout, clean, stash, or overwrite unrelated changes.", + "Do not copy secrets or inspect local secret files.", + "Do not mutate Taskstream/Redmine/story state through direct database writes.", + "Do not mark done unless validation passes and evidence is written.", + "Do not fabricate test results. If validation cannot run, record why in the handoff note.", + ] + for phrase in required_phrases: + if phrase not in text: + errors.append(f"{rel(path)} missing safety phrase: {phrase}") + for regex in SECRET_VALUE_REGEXES: + if regex.search(text): + errors.append(f"{rel(path)} contains secret-like value matching {regex.pattern}") + return errors + + +def validate_packet_bundle(run_dir: Path) -> dict[str, Any]: + """Validate bundle metadata, index references, packet sections, hashes, and path ownership.""" + resolved_run_dir = resolve_path(run_dir) + errors: list[str] = [] + warnings: list[str] = [] + checked_packets: list[str] = [] + try: + bundle = safe_read_json(resolved_run_dir / "codex-packet-bundle.json") + index = safe_read_json(resolved_run_dir / "codex-packet-index.json") + except CodexPacketError as exc: + return { + "ok": False, + "run_id": resolved_run_dir.name, + "packet_count": 0, + "checked_packets": [], + "errors": [str(exc)], + "warnings": [], + } + packets = index.get("packets") + if not isinstance(packets, list): + packets = [] + errors.append("codex-packet-index.json packets must be a list") + if bundle.get("artifact_type") != "codex-packet-bundle": + errors.append("codex-packet-bundle.json artifact_type must be codex-packet-bundle") + if index.get("artifact_type") != "codex-packet-index": + errors.append("codex-packet-index.json artifact_type must be codex-packet-index") + if int(bundle.get("packet_count") or -1) != len(packets): + errors.append("bundle packet_count does not match index packet count") + + owned: list[tuple[str, str]] = [] + for item in packets: + if not isinstance(item, dict): + errors.append("packet index entry must be an object") + continue + task_id = str(item.get("task_id") or "") + packet_rel = str(item.get("path") or "") + packet_path = resolved_run_dir / packet_rel + if not task_id: + errors.append("packet index entry missing task_id") + if not packet_path.exists(): + errors.append(f"packet file missing: {packet_rel}") + continue + checked_packets.append(task_id) + errors.extend(validate_packet_file(packet_path)) + digest = sha256_file(packet_path) + if digest != item.get("sha256"): + errors.append(f"packet hash mismatch: {task_id}") + if not isinstance(item.get("owned_write_paths"), list): + errors.append(f"{task_id} owned_write_paths must be a list") + elif not item.get("requires_manual_review") and not item.get("owned_write_paths"): + errors.append(f"{task_id} owned_write_paths must not be empty") + if not isinstance(item.get("read_only_paths"), list): + errors.append(f"{task_id} read_only_paths must be a list") + if not isinstance(item.get("prohibited_paths"), list): + errors.append(f"{task_id} prohibited_paths must be a list") + if not isinstance(item.get("validation_commands"), list): + errors.append(f"{task_id} validation_commands must be a list") + if not item.get("evidence_path"): + errors.append(f"{task_id} evidence_path missing") + if not item.get("patch_bundle_path"): + errors.append(f"{task_id} patch_bundle_path missing") + for owned_path in item.get("owned_write_paths") or []: + try: + normalized = normalize_relative_path(str(owned_path)) + except CodexPacketError as exc: + errors.append(f"{task_id} invalid owned path: {exc}") + continue + owned.append((task_id, normalized.rstrip("/"))) + + for index_a, (task_a, path_a) in enumerate(owned): + for task_b, path_b in owned[index_a + 1 :]: + if path_overlaps(path_a, path_b): + errors.append(f"overlapping owned paths: {task_a}:{path_a} and {task_b}:{path_b}") + return { + "ok": not errors, + "run_id": str(bundle.get("run_id") or index.get("run_id") or resolved_run_dir.name), + "packet_count": len(packets), + "checked_packets": checked_packets, + "errors": errors, + "warnings": warnings, + } + + +def write_validation_report(path: Path, validation: dict[str, Any]) -> None: + lines = [ + "# Codex Packet Validation Report", + "", + "## Summary", + "", + f"- OK: `{validation.get('ok')}`", + f"- Run ID: `{validation.get('run_id')}`", + f"- Packet count: `{validation.get('packet_count')}`", + "", + "## Errors", + "", + *(f"- {item}" for item in validation.get("errors", [])), + *(["- None"] if not validation.get("errors") else []), + "", + "## Warnings", + "", + *(f"- {item}" for item in validation.get("warnings", [])), + *(["- None"] if not validation.get("warnings") else []), + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _fixture_task(index: int, lane: str, run_id: str) -> dict[str, Any]: + task_id = f"task-{index:04d}" + profile, risk = LANE_PROFILES[lane] + base = f"workspace/runs/parallel-delivery/{run_id}/task-work/{task_id}" + return { + "acceptance_contract": [ + "Packet instructions are bounded to the leased owned path.", + "Validation commands run or exact blocker evidence is written.", + "Patch bundle output instructions and handoff note are complete.", + ], + "dependencies": [f"task-{index - 1:04d}"] if lane == "integrator" and index > 1 else [], + "evidence_pointers": [], + "expected_artifacts": [f"{base}/evidence.json"], + "human_handoff": False, + "integration_notes": ["Later Safe Integrator calls decide apply order; this packet does not apply patches."], + "lane": lane, + "owned_paths": [base], + "read_only_paths": ["docs/patch-swarm.md", "docs/parallel-delivery/patch-swarm-artifacts.md"], + "rejection_triggers": [ + "Touches an unowned path.", + "Requires a secret, live service, direct DB mutation, or protected path edit.", + "Cannot produce validation evidence.", + ], + "risk_tier": risk, + "state": "leased", + "story": f"As a Cento operator, I need a {lane} packet fixture for {task_id}.", + "summary": f"Produce deterministic {lane} Codex worker packet evidence for {task_id}.", + "task_id": task_id, + "title": f"Codex packet fixture {task_id} {lane} lane", + "validation_commands": [ + "python3 -m json.tool data/tools.json >/dev/null", + "python3 -m json.tool data/cento-cli.json >/dev/null", + f"test -f workspace/runs/parallel-delivery/{run_id}/codex-packet-index.json", + ], + "worker_profile": profile, + } + + +def build_codex_packets_fixture(run_dir: Path, *, run_id: str, count: int, timestamp: str) -> CodexPacketResult: + """Generate deterministic split plan, task graph, path leases, and worker packets.""" + if count < DEFAULT_PACKET_COUNT: + raise CodexPacketError("fixture count must be at least 10") + resolved_run_dir = resolve_path(run_dir) + resolved_run_dir.mkdir(parents=True, exist_ok=True) + lanes = [LANE_ORDER[index % len(LANE_ORDER)] for index in range(count)] + tasks = [_fixture_task(index + 1, lane, run_id) for index, lane in enumerate(lanes)] + request_text = ( + "# Codex Packets Fixture\n\n" + "Generate local-only Patch Swarm Codex worker packets covering builder, validator, docs-evidence, coordinator, and integrator lanes.\n" + ) + (resolved_run_dir / "request.md").write_text(request_text, encoding="utf-8") + split_plan = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "split-plan", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": {"producer": PRODUCER, "command": "write-fixture", "source": "fixture", "notes": []}, + "evidence_pointers": [], + "candidate_count": count, + "candidate_target": count, + "max_candidate_tasks": count, + "max_parallel_agents": min(5, count), + "planner_mode": "fixture", + "lanes": [*LANE_ORDER, "human-handoff"], + "request": { + "request_file": "request.md", + "summary": "Generate deterministic local Codex worker packets.", + "title": "Codex Packets Fixture", + }, + "planning_policy": { + "avoid_overlapping_owned_paths": True, + "coarse_lanes_first": True, + "do_not_blindly_fill_to_target": False, + "human_handoff_for_subjective_or_device_bound": True, + }, + "tasks": tasks, + } + write_json(resolved_run_dir / "split-plan.json", split_plan) + task_graph = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "task-graph", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": {"producer": PRODUCER, "command": "write-fixture", "source": "fixture", "notes": []}, + "evidence_pointers": [], + "max_parallel_agents": min(5, count), + "nodes": [ + { + "task_id": task["task_id"], + "lane": task["lane"], + "risk_tier": task["risk_tier"], + "human_handoff": False, + "owned_paths": task["owned_paths"], + } + for task in tasks + ], + "edges": [ + {"from": dep, "to": task["task_id"], "type": "depends_on", "reason": "fixture integrator dependency"} + for task in tasks + for dep in task["dependencies"] + ], + "topological_order": [task["task_id"] for task in tasks], + } + write_json(resolved_run_dir / "task-graph.json", task_graph) + leases = [] + for index, task in enumerate(tasks, start=1): + leases.append( + { + "lease_id": f"lease-{task['task_id']}", + "task_id": task["task_id"], + "state": "active", + "created_at": timestamp, + "owned_paths": task["owned_paths"], + "read_only_paths": task["read_only_paths"], + "guarded_paths": ["data/tools.json", "data/cento-cli.json"], + "protected_paths": [".env", ".env.*", ".env.mcp", ".git/**"], + "dirty_owned_paths": [], + "requires_manual_review": False, + "minimal_hunk_required": True, + "dependency_gates": task["dependencies"], + "parallel_group": f"group-{((index - 1) % 5) + 1}", + } + ) + path_leases = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "path-leases", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": {"producer": PRODUCER, "command": "write-fixture", "source": "fixture", "notes": []}, + "evidence_pointers": [], + "leases": leases, + "conflicts": [], + } + write_json(resolved_run_dir / "path-leases.json", path_leases) + return write_packet_bundle( + CodexPacketRequest( + run_id=run_id, + run_dir=resolved_run_dir, + count=count, + fixed_timestamp=timestamp, + ) + ) + + +def print_policy() -> dict[str, Any]: + """Return local worker packet generator policy.""" + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "codex-packet-policy", + "producer": PRODUCER, + "local_only": True, + "no_api_calls": True, + "no_secrets": True, + "owned_paths_required": True, + "workset_compatible": True, + "patch_bundle_required": True, + "evidence_required": True, + "supported_lanes": sorted(SUPPORTED_LANES), + "required_sections": REQUIRED_PACKET_SECTIONS, + "secret_protected_patterns": SECRET_PROTECTED_PATTERNS, + "prohibited_paths": BASE_PROHIBITED_PATHS, + } + + +def result_payload(result: CodexPacketResult) -> dict[str, Any]: + return { + "ok": not result.errors, + "run_id": result.run_id, + "run_dir": rel(result.run_dir), + "packet_count": result.packet_count, + "bundle": rel(result.bundle_path), + "index": rel(result.index_path), + "packets": result.packets, + "warnings": result.warnings, + "errors": result.errors, + } + + +def command_print_policy(args: argparse.Namespace) -> int: + payload = print_policy() + print(stable_json_dumps(payload) if args.json else stable_json_dumps(payload), end="") + return 0 + + +def command_write_fixture(args: argparse.Namespace) -> int: + try: + result = build_codex_packets_fixture( + Path(args.run_dir), + run_id=args.run_id, + count=args.count, + timestamp=args.fixed_timestamp or "2026-01-01T00:00:00Z", + ) + payload = result_payload(result) + except CodexPacketError as exc: + payload = {"ok": False, "run_id": args.run_id, "packet_count": 0, "errors": [str(exc)], "warnings": []} + print(stable_json_dumps(payload) if args.json else stable_json_dumps(payload), end="") + return 0 if payload.get("ok") else 1 + + +def command_generate(args: argparse.Namespace) -> int: + try: + result = write_packet_bundle( + CodexPacketRequest( + run_id=args.run_id, + run_dir=Path(args.run_dir), + count=args.count, + split_plan_path=Path(args.split_plan) if args.split_plan else None, + task_graph_path=Path(args.task_graph) if args.task_graph else None, + path_leases_path=Path(args.path_leases) if args.path_leases else None, + fixed_timestamp=args.fixed_timestamp or None, + ) + ) + payload = result_payload(result) + except CodexPacketError as exc: + payload = {"ok": False, "run_id": args.run_id or Path(args.run_dir).name, "packet_count": 0, "errors": [str(exc)], "warnings": []} + print(stable_json_dumps(payload) if args.json else stable_json_dumps(payload), end="") + return 0 if payload.get("ok") else 1 + + +def command_validate_bundle(args: argparse.Namespace) -> int: + payload = validate_packet_bundle(Path(args.run_dir)) + print(stable_json_dumps(payload) if args.json else stable_json_dumps(payload), end="") + return 0 if payload.get("ok") else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate local Patch Swarm Codex worker packets.") + sub = parser.add_subparsers(dest="command", required=True) + + policy = sub.add_parser("print-policy", help="Print local worker packet policy.") + policy.add_argument("--json", action="store_true") + policy.set_defaults(func=command_print_policy) + + fixture = sub.add_parser("write-fixture", help="Write deterministic fixture inputs and Codex packets.") + fixture.add_argument("--run-dir", required=True) + fixture.add_argument("--run-id", default="codex-packets-fixture") + fixture.add_argument("--count", type=int, default=DEFAULT_PACKET_COUNT) + fixture.add_argument("--fixed-timestamp", default="2026-01-01T00:00:00Z") + fixture.add_argument("--json", action="store_true") + fixture.set_defaults(func=command_write_fixture) + + generate = sub.add_parser("generate", help="Generate Codex packets from existing split/task/lease artifacts.") + generate.add_argument("--run-dir", required=True) + generate.add_argument("--run-id", default="") + generate.add_argument("--count", type=int, default=None) + generate.add_argument("--split-plan", default="") + generate.add_argument("--task-graph", default="") + generate.add_argument("--path-leases", default="") + generate.add_argument("--fixed-timestamp", default="") + generate.add_argument("--json", action="store_true") + generate.set_defaults(func=command_generate) + + validate = sub.add_parser("validate-bundle", help="Validate a generated Codex packet bundle.") + validate.add_argument("--run-dir", required=True) + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_validate_bundle) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_leases.py b/scripts/parallel_delivery_leases.py new file mode 100644 index 0000000..c6ff77d --- /dev/null +++ b/scripts/parallel_delivery_leases.py @@ -0,0 +1,1638 @@ +#!/usr/bin/env python3 +"""Patch Swarm path lease helper. + +This module creates and validates Patch Swarm path leases. It is a safety and +evidence layer only: it does not dispatch workers, apply patches, or mutate +Taskstream/Redmine state. +""" + +from __future__ import annotations + +import argparse +import copy +import fnmatch +import hashlib +import json +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +try: + import cento_workset # type: ignore # noqa: E402 +except Exception: # pragma: no cover - import availability is reported in compatibility output + cento_workset = None # type: ignore + + +CURRENT_SCHEMA_VERSION = 1 +PRODUCER = "cento.parallel-delivery.leases" + +LEASE_STATES = { + "proposed", + "active", + "blocked", + "conflict", + "released", + "expired", + "rejected", +} + +ALLOWED_OPERATIONS = { + "create", + "modify", + "delete", + "rename", +} + +BLOCKED_OPERATIONS = { + "binary_patch", + "broad_cleanup", +} + +SECRET_PROTECTED_PATTERNS = [ + ".env", + ".env.*", + ".env.mcp", + "*.pem", + "*.key", + "*secret*", + "*token*", + "*credential*", +] + +GUARDED_PATHS = { + "data/tools.json", + "data/cento-cli.json", + "Makefile", + "pyproject.toml", + "setup.cfg", + "tox.ini", + "pytest.ini", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "poetry.lock", + "requirements.lock", + "Pipfile.lock", + "Cargo.lock", + "go.sum", +} + +LOCKFILE_NAMES = { + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "npm-shrinkwrap.json", + "Cargo.lock", + "Gemfile.lock", + "Pipfile.lock", + "poetry.lock", + "requirements.lock", + "uv.lock", + "go.sum", +} + +LEASE_REQUIRED_FIELDS = [ + "lease_id", + "task_id", + "state", + "lane", + "risk_tier", + "owned_paths", + "read_only_paths", + "guarded_paths", + "protected_paths", + "dirty_owned_paths", + "allowed_operations", + "blocked_operations", + "dependencies", + "dependency_gate", + "parallel_group", + "requires_minimal_hunks", + "requires_manual_review", + "created_at", + "evidence_pointers", +] + +TOP_LEVEL_REQUIRED_FIELDS = [ + "schema_version", + "artifact_type", + "run_id", + "created_at", + "updated_at", + "provenance", + "lease_policy", + "leases", + "conflicts", + "dependency_gates", + "parallel_groups", + "workset_manifest", + "dirty_targets", + "warnings", + "evidence_pointers", +] + +ISO_Z_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") +MARKDOWN_PREFIX = "" + + +def lease_policy() -> dict[str, Any]: + """Return the Patch Swarm lease policy.""" + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "read_many_write_few": True, + "reject_overlapping_owned_paths": True, + "reject_protected_secret_paths": True, + "guard_lockfiles": True, + "reject_unowned_renames": True, + "reject_unsafe_deletes": True, + "reject_binary_patches": True, + "reject_broad_cleanup": True, + "dirty_targets_require_minimal_hunks": True, + "protected_patterns": sorted(SECRET_PROTECTED_PATTERNS), + "guarded_paths": sorted(GUARDED_PATHS), + } + + +def provenance(command: str, source: str = "split-plan/task-graph") -> dict[str, Any]: + return { + "producer": PRODUCER, + "command": command, + "source": source, + "repo": "cento", + "notes": [], + } + + +def normalize_repo_path(path: str) -> str: + """Normalize and validate a relative repo path.""" + value = str(path).strip().replace("\\", "/") + value = re.sub(r"/+", "/", value) + while value.startswith("./"): + value = value[2:] + value = value.rstrip("/") + if value in {"", ".", "/", "*", "**"}: + raise LeaseValidationError(f"{path}: broad cleanup or repo-root paths are not allowed") + if value.startswith("/") or value.startswith("~"): + raise LeaseValidationError(f"{path}: absolute or home-relative paths are not allowed") + parts = value.split("/") + if ".." in parts: + raise LeaseValidationError(f"{path}: parent traversal is not allowed") + if parts[0] == ".git" or ".git" in parts: + raise LeaseValidationError(f"{path}: .git paths are not allowed") + if is_secret_protected_path(value): + raise LeaseValidationError(f"{path}: protected secret-like paths are not allowed") + return value + + +def normalize_path_list(paths: Any, field: str) -> tuple[list[str], list[str], list[str]]: + normalized: list[str] = [] + protected: list[str] = [] + errors: list[str] = [] + if paths is None: + return [], [], [] + if not isinstance(paths, list): + return [], [], [f"{field} must be a list"] + for item in paths: + if not isinstance(item, str): + errors.append(f"{field} entries must be strings") + continue + try: + normalized.append(normalize_repo_path(item)) + except LeaseValidationError as exc: + errors.append(str(exc)) + protected.append(str(item)) + return sorted(dict.fromkeys(normalized)), sorted(dict.fromkeys(protected)), errors + + +def is_secret_protected_path(path: str) -> bool: + """Return true for always-rejected secret/protected paths.""" + lowered = str(path).replace("\\", "/").lower().strip() + parts = [part for part in lowered.split("/") if part] + candidates = [lowered, *parts] + for candidate in candidates: + for pattern in SECRET_PROTECTED_PATTERNS: + if fnmatch.fnmatch(candidate, pattern.lower()): + return True + return False + + +def is_lockfile_path(path: str) -> bool: + return path.split("/")[-1] in LOCKFILE_NAMES + + +def is_guarded_path(path: str) -> bool: + """Return true for registry/config/lockfile paths requiring explicit contract and review.""" + return path in GUARDED_PATHS or is_lockfile_path(path) + + +def paths_overlap(a: str, b: str) -> bool: + """Return true for exact or parent/child write path overlap.""" + left = a.rstrip("/") + right = b.rstrip("/") + return left == right or left.startswith(right + "/") or right.startswith(left + "/") + + +def path_is_owned(path: str, owned_paths: list[str]) -> bool: + return any(path == owned or path.startswith(owned.rstrip("/") + "/") for owned in owned_paths) + + +def _conflict( + index: int, + conflict_type: str, + task_ids: list[str], + paths: list[str], + reason: str, + *, + resolution: str = "narrow one lease, add dependency gate, or group tasks sequentially", + severity: str = "error", +) -> dict[str, Any]: + return { + "conflict_id": f"conflict-{index:04d}", + "type": conflict_type, + "severity": severity, + "task_ids": sorted(dict.fromkeys(task_ids)), + "paths": sorted(dict.fromkeys(paths)), + "reason": reason, + "resolution": resolution, + } + + +def detect_owned_path_conflicts(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Detect exact and parent/child write/write conflicts.""" + conflicts: list[dict[str, Any]] = [] + owned: list[tuple[str, str]] = [] + for task in tasks: + for path in task.get("owned_paths", []): + owned.append((str(task.get("task_id") or "unknown"), str(path))) + for index, (task_a, path_a) in enumerate(owned): + for task_b, path_b in owned[index + 1 :]: + if path_a == path_b: + conflicts.append( + _conflict( + len(conflicts) + 1, + "owned_path_overlap", + [task_a, task_b], + [path_a, path_b], + "exact owned path overlap", + ) + ) + elif paths_overlap(path_a, path_b): + conflicts.append( + _conflict( + len(conflicts) + 1, + "owned_path_overlap", + [task_a, task_b], + [path_a, path_b], + "parent/child owned path overlap", + ) + ) + return conflicts + + +def parse_git_status_porcelain(text: str) -> list[dict[str, str]]: + """Parse git status --porcelain=v1 without reading file contents.""" + rows: list[dict[str, str]] = [] + for line in text.splitlines(): + if not line: + continue + status = line[:2] + raw_path = line[3:] if len(line) > 3 else line[2:].strip() + old_path = "" + if " -> " in raw_path: + old_path, raw_path = raw_path.split(" -> ", 1) + rows.append({"status": status.strip() or status, "path": raw_path, "old_path": old_path}) + return rows + + +def detect_dirty_targets(tasks: list[dict[str, Any]], dirty_files: list[dict[str, str]]) -> list[dict[str, Any]]: + """Find dirty files under owned paths and produce warnings.""" + dirty_targets: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for dirty in dirty_files: + raw_path = dirty.get("path", "") + try: + dirty_path = normalize_repo_path(raw_path) + except LeaseValidationError: + continue + for task in tasks: + task_id = str(task.get("task_id")) + owned_paths = [str(path) for path in task.get("owned_paths", [])] + if any(paths_overlap(dirty_path, owned) for owned in owned_paths): + key = (dirty_path, task_id) + if key in seen: + continue + seen.add(key) + dirty_targets.append( + { + "path": dirty_path, + "status": dirty.get("status", ""), + "task_ids": [task_id], + "risk": "high", + "required_handling": ( + "inspect before editing; preserve unrelated hunks; minimal additive hunks only; " + "no reset/checkout/clean/stash" + ), + } + ) + return dirty_targets + + +def make_lease_id(run_id: str, task_id: str, owned_paths: list[str], read_only_paths: list[str]) -> str: + """Create stable lease-task-id-hash.""" + payload = { + "owned_paths": sorted(owned_paths), + "read_only_paths": sorted(read_only_paths), + "run_id": run_id, + "task_id": task_id, + } + digest = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12] + return f"lease-{task_id}-{digest}" + + +def task_contract_allows_lockfile(task: dict[str, Any]) -> bool: + text = " ".join( + str(item) + for field in ("acceptance_contract", "validation_commands", "title", "summary") + for item in (task.get(field) if isinstance(task.get(field), list) else [task.get(field, "")]) + ).lower() + return "lockfile" in text or "lock file" in text or "package" in text or "dependency" in text + + +def extract_task_contracts(split_plan: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: + errors: list[str] = [] + tasks: list[dict[str, Any]] = [] + raw_tasks = split_plan.get("tasks") + if not isinstance(raw_tasks, list) or not raw_tasks: + return [], ["split-plan.tasks must be a non-empty list"] + for index, raw_task in enumerate(raw_tasks, start=1): + if not isinstance(raw_task, dict): + errors.append(f"split-plan.tasks[{index}] must be an object") + continue + task_id = str(raw_task.get("task_id") or raw_task.get("id") or f"task-{index:04d}") + owned_paths, protected_owned, owned_errors = normalize_path_list( + raw_task.get("owned_paths", raw_task.get("write_paths", [])), + f"{task_id}.owned_paths", + ) + read_only_paths, protected_read, read_errors = normalize_path_list( + raw_task.get("read_only_paths", raw_task.get("read_paths", [])), + f"{task_id}.read_only_paths", + ) + errors.extend(owned_errors) + errors.extend(read_errors) + task = { + "task_id": task_id, + "title": str(raw_task.get("title") or raw_task.get("task") or task_id), + "summary": str(raw_task.get("summary") or raw_task.get("description") or ""), + "lane": str(raw_task.get("lane") or "builder"), + "risk_tier": str(raw_task.get("risk_tier") or "medium"), + "human_handoff": bool(raw_task.get("human_handoff", False)), + "owned_paths": owned_paths, + "read_only_paths": read_only_paths, + "protected_paths": sorted(dict.fromkeys([*protected_owned, *protected_read])), + "dependencies": [str(item) for item in raw_task.get("dependencies", raw_task.get("depends_on", [])) or []], + "acceptance_contract": [str(item) for item in raw_task.get("acceptance_contract", []) or []], + "validation_commands": [str(item) for item in raw_task.get("validation_commands", []) or []], + "lockfile_contract_ok": task_contract_allows_lockfile(raw_task), + } + tasks.append(task) + return tasks, errors + + +def _dependency_edges(task_graph: dict[str, Any] | None, tasks: list[dict[str, Any]]) -> list[dict[str, str]]: + edges: list[dict[str, str]] = [] + if isinstance(task_graph, dict): + for edge in task_graph.get("edges", []): + if isinstance(edge, dict) and edge.get("type") == "depends_on": + edges.append({"from": str(edge.get("from")), "to": str(edge.get("to")), "type": "depends_on"}) + known = {task["task_id"] for task in tasks} + existing = {(edge["from"], edge["to"]) for edge in edges} + for task in tasks: + for dep in task.get("dependencies", []): + if dep in known and (dep, task["task_id"]) not in existing: + edges.append({"from": dep, "to": task["task_id"], "type": "depends_on"}) + return edges + + +def _topological_order(tasks: list[dict[str, Any]], edges: list[dict[str, str]]) -> list[str]: + ids = sorted(task["task_id"] for task in tasks) + incoming = {task_id: 0 for task_id in ids} + outgoing: dict[str, list[str]] = {task_id: [] for task_id in ids} + for edge in edges: + source = edge.get("from", "") + target = edge.get("to", "") + if source not in incoming or target not in incoming: + continue + outgoing[source].append(target) + incoming[target] += 1 + ready = sorted(task_id for task_id, count in incoming.items() if count == 0) + order: list[str] = [] + while ready: + current = ready.pop(0) + order.append(current) + for target in sorted(outgoing.get(current, [])): + incoming[target] -= 1 + if incoming[target] == 0: + ready.append(target) + ready.sort() + if len(order) != len(ids): + return ids + return order + + +def build_dependency_gates( + split_plan: dict[str, Any], + task_graph: dict[str, Any] | None, + leases: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Create dependency gates from task dependencies and guarded/dirty/manual review constraints.""" + tasks, _ = extract_task_contracts(split_plan) + edges = _dependency_edges(task_graph, tasks) + gates: list[dict[str, Any]] = [] + for edge in edges: + gates.append( + { + "gate_id": f"gate-{len(gates) + 1:04d}", + "type": "dependency", + "before": [edge["from"]], + "after": [edge["to"]], + "reason": f"{edge['to']} depends on {edge['from']}", + "enforced_by": "task-graph", + } + ) + for lease in leases: + if lease.get("guarded_paths"): + gates.append( + { + "gate_id": f"gate-{len(gates) + 1:04d}", + "type": "guarded_path", + "before": [], + "after": [lease["task_id"]], + "reason": "guarded path requires manual review and minimal hunks", + "enforced_by": "path-lease-policy", + } + ) + if lease.get("dirty_owned_paths"): + gates.append( + { + "gate_id": f"gate-{len(gates) + 1:04d}", + "type": "dirty_target", + "before": [], + "after": [lease["task_id"]], + "reason": "dirty owned path requires preserving unrelated hunks", + "enforced_by": "git-status", + } + ) + if lease.get("requires_manual_review") or lease.get("lane") == "human-handoff": + gates.append( + { + "gate_id": f"gate-{len(gates) + 1:04d}", + "type": "manual_review", + "before": [], + "after": [lease["task_id"]], + "reason": "manual review required before automated integration", + "enforced_by": "path-lease-policy", + } + ) + return gates + + +def build_parallel_groups( + split_plan: dict[str, Any], + task_graph: dict[str, Any] | None, + leases: list[dict[str, Any]], + dependency_gates: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Group tasks that can safely run in parallel.""" + tasks, _ = extract_task_contracts(split_plan) + edges = _dependency_edges(task_graph, tasks) + order = task_graph.get("topological_order") if isinstance(task_graph, dict) else None + if not isinstance(order, list) or set(str(item) for item in order) != {task["task_id"] for task in tasks}: + order = _topological_order(tasks, edges) + max_parallel = int(split_plan.get("max_parallel_agents") or split_plan.get("max_parallel") or 5) + leases_by_task = {lease["task_id"]: lease for lease in leases} + dependency_targets = {edge["to"] for edge in edges} + groups: list[dict[str, Any]] = [] + current: list[str] = [] + + def flush(reason: str = "non-overlapping owned paths and no dependency gate") -> None: + nonlocal current + if current: + groups.append( + { + "group_id": f"parallel-group-{len(groups) + 1:04d}", + "task_ids": current, + "safe_parallel": True, + "reason": reason, + } + ) + current = [] + + for task_id_value in order: + task_id = str(task_id_value) + lease = leases_by_task.get(task_id) + if not lease: + continue + manual = bool(lease.get("requires_manual_review")) or lease.get("lane") == "human-handoff" + blocked = lease.get("state") not in {"active", "proposed", "released"} + has_dependency = task_id in dependency_targets + if manual or blocked or has_dependency: + flush() + groups.append( + { + "group_id": f"parallel-group-{len(groups) + 1:04d}", + "task_ids": [task_id], + "safe_parallel": not blocked and not manual, + "reason": ( + "manual review or high-risk guarded path" + if manual + else "dependency gate requires sequential placement" + if has_dependency + else "lease is blocked or rejected" + ), + } + ) + continue + current.append(task_id) + if len(current) >= max_parallel: + flush() + flush() + return groups + + +def create_leases( + split_plan: dict[str, Any], + task_graph: dict[str, Any] | None, + *, + git_status_text: str, + timestamp: str, + command: str = "patch-swarm leases", +) -> dict[str, Any]: + """Create path-leases.json payload.""" + run_id = str(split_plan.get("run_id") or (task_graph or {}).get("run_id") or "lease-fixture") + tasks, task_errors = extract_task_contracts(split_plan) + dirty_files = parse_git_status_porcelain(git_status_text) + dirty_targets = detect_dirty_targets(tasks, dirty_files) + dirty_by_task: dict[str, list[str]] = {} + for target in dirty_targets: + for task_id in target["task_ids"]: + dirty_by_task.setdefault(task_id, []).append(target["path"]) + + leases: list[dict[str, Any]] = [] + conflicts: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + for index, task in enumerate(tasks, start=1): + task_id = task["task_id"] + guarded_paths = sorted(path for path in task["owned_paths"] if is_guarded_path(path)) + dirty_owned_paths = sorted(dict.fromkeys(dirty_by_task.get(task_id, []))) + lockfiles = sorted(path for path in task["owned_paths"] if is_lockfile_path(path)) + protected_paths = list(task.get("protected_paths", [])) + risk_tier = task.get("risk_tier") or "medium" + requires_manual_review = bool(task.get("human_handoff") or guarded_paths or dirty_owned_paths or protected_paths) + requires_minimal_hunks = bool(guarded_paths or dirty_owned_paths or protected_paths) + state = "active" + if task.get("human_handoff"): + risk_tier = "human" + if guarded_paths or dirty_owned_paths or protected_paths: + risk_tier = "high" if risk_tier != "human" else "human" + if protected_paths: + state = "rejected" + conflicts.append( + _conflict( + len(conflicts) + 1, + "protected_path", + [task_id], + protected_paths, + "always-protected secret-like path is rejected", + resolution="remove secret-like paths from the task contract", + ) + ) + for lockfile in lockfiles: + if not task.get("lockfile_contract_ok"): + state = "rejected" + conflicts.append( + _conflict( + len(conflicts) + 1, + "lockfile_contract_missing", + [task_id], + [lockfile], + "lockfile changes require explicit lockfile/package dependency validation in the task contract", + resolution="add explicit lockfile/package dependency validation or remove the lockfile path", + ) + ) + for dirty_path in dirty_owned_paths: + warnings.append( + { + "type": "dirty_target", + "task_id": task_id, + "path": dirty_path, + "message": "dirty target; preserve unrelated hunks; minimal additive edits only; no reset/checkout/clean/stash", + } + ) + lease = { + "lease_id": make_lease_id(run_id, task_id, task["owned_paths"], task["read_only_paths"]), + "task_id": task_id, + "state": state, + "lane": task.get("lane") or "builder", + "risk_tier": risk_tier, + "owned_paths": task["owned_paths"], + "read_only_paths": task["read_only_paths"], + "guarded_paths": guarded_paths, + "protected_paths": protected_paths, + "dirty_owned_paths": dirty_owned_paths, + "allowed_operations": ["create", "modify"], + "blocked_operations": ["delete", "rename", "binary_patch", "broad_cleanup"], + "dependencies": task.get("dependencies", []), + "dependency_gate": None, + "parallel_group": None, + "requires_minimal_hunks": requires_minimal_hunks, + "requires_manual_review": requires_manual_review, + "contract_allows_lockfile": bool(task.get("lockfile_contract_ok")), + "created_at": timestamp, + "evidence_pointers": [], + } + if not lease["owned_paths"] and not lease["requires_manual_review"]: + lease["state"] = "blocked" + warnings.append({"type": "missing_owned_paths", "task_id": task_id, "message": "automated tasks should declare owned paths"}) + leases.append(lease) + + normalized_tasks = [{"task_id": lease["task_id"], "owned_paths": lease["owned_paths"]} for lease in leases if lease["state"] == "active"] + conflicts.extend(detect_owned_path_conflicts(normalized_tasks)) + conflicted_tasks = {task_id for conflict in conflicts for task_id in conflict.get("task_ids", []) if conflict.get("type") == "owned_path_overlap"} + for lease in leases: + if lease["task_id"] in conflicted_tasks: + lease["state"] = "conflict" + + dependency_gates = build_dependency_gates(split_plan, task_graph, leases) + gate_by_task: dict[str, str] = {} + for gate in dependency_gates: + for task_id in gate.get("after", []): + gate_by_task.setdefault(str(task_id), str(gate.get("gate_id"))) + for lease in leases: + lease["dependency_gate"] = gate_by_task.get(lease["task_id"]) + + parallel_groups = build_parallel_groups(split_plan, task_graph, leases, dependency_gates) + group_by_task: dict[str, int] = {} + for index, group in enumerate(parallel_groups, start=1): + for task_id in group.get("task_ids", []): + group_by_task[str(task_id)] = index + for lease in leases: + lease["parallel_group"] = group_by_task.get(lease["task_id"]) + + for error in task_errors: + conflicts.append( + _conflict( + len(conflicts) + 1, + "task_contract_invalid", + [], + [], + error, + resolution="fix split-plan path and task contract fields", + ) + ) + + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "path-leases", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": provenance(command), + "lease_policy": lease_policy(), + "leases": leases, + "conflicts": conflicts, + "dependency_gates": dependency_gates, + "parallel_groups": parallel_groups, + "workset_manifest": None, + "dirty_targets": dirty_targets, + "warnings": warnings, + "evidence_pointers": [], + } + + +def _validate_iso(value: Any, field: str) -> list[str]: + if not isinstance(value, str) or not ISO_Z_RE.match(value): + return [f"{field} must be ISO-8601 UTC with trailing Z"] + return [] + + +def validate_path_list(paths: Any, field: str) -> list[str]: + if not isinstance(paths, list): + return [f"{field} must be a list"] + errors: list[str] = [] + for index, item in enumerate(paths, start=1): + if not isinstance(item, str): + errors.append(f"{field}[{index}] must be a string") + continue + try: + normalize_repo_path(item) + except LeaseValidationError as exc: + errors.append(f"{field}[{index}]: {exc}") + return errors + + +def validate_path_leases(path_leases: dict[str, Any]) -> list[str]: + """Validate required fields, path safety, non-overlap, and lease policy.""" + errors: list[str] = [] + for field in TOP_LEVEL_REQUIRED_FIELDS: + if field not in path_leases: + errors.append(f"path-leases missing {field}") + if path_leases.get("schema_version") != CURRENT_SCHEMA_VERSION: + errors.append(f"schema_version must be {CURRENT_SCHEMA_VERSION}") + if path_leases.get("artifact_type") != "path-leases": + errors.append("artifact_type must be path-leases") + if not isinstance(path_leases.get("run_id"), str) or not path_leases.get("run_id"): + errors.append("run_id must be a non-empty string") + errors.extend(_validate_iso(path_leases.get("created_at"), "created_at")) + errors.extend(_validate_iso(path_leases.get("updated_at"), "updated_at")) + if not isinstance(path_leases.get("lease_policy"), dict): + errors.append("lease_policy must be an object") + leases = path_leases.get("leases") + if not isinstance(leases, list): + return errors + ["leases must be a list"] + + active_tasks: list[dict[str, Any]] = [] + for index, lease in enumerate(leases, start=1): + label = f"leases[{index}]" + if not isinstance(lease, dict): + errors.append(f"{label} must be an object") + continue + for field in LEASE_REQUIRED_FIELDS: + if field not in lease: + errors.append(f"{label} missing {field}") + if lease.get("state") not in LEASE_STATES: + errors.append(f"{label}.state must be a known lease state") + errors.extend(_validate_iso(lease.get("created_at"), f"{label}.created_at")) + errors.extend(validate_path_list(lease.get("owned_paths"), f"{label}.owned_paths")) + errors.extend(validate_path_list(lease.get("read_only_paths"), f"{label}.read_only_paths")) + errors.extend(validate_path_list(lease.get("guarded_paths"), f"{label}.guarded_paths")) + errors.extend(validate_path_list(lease.get("dirty_owned_paths"), f"{label}.dirty_owned_paths")) + protected_paths = lease.get("protected_paths") + if not isinstance(protected_paths, list): + errors.append(f"{label}.protected_paths must be a list") + elif protected_paths: + errors.append(f"{label}.protected_paths must be empty for valid leases: {', '.join(map(str, protected_paths))}") + for field in ["requires_minimal_hunks", "requires_manual_review"]: + if not isinstance(lease.get(field), bool): + errors.append(f"{label}.{field} must be boolean") + for guarded_path in lease.get("guarded_paths", []) if isinstance(lease.get("guarded_paths"), list) else []: + if guarded_path not in lease.get("owned_paths", []): + errors.append(f"{label}.guarded_paths must also be owned: {guarded_path}") + if lease.get("risk_tier") not in {"high", "human"}: + errors.append(f"{label}: guarded paths require high risk tier") + if lease.get("requires_manual_review") is not True: + errors.append(f"{label}: guarded paths require manual review") + if lease.get("requires_minimal_hunks") is not True: + errors.append(f"{label}: guarded paths require minimal hunks") + if lease.get("dirty_owned_paths"): + if lease.get("risk_tier") not in {"high", "human"}: + errors.append(f"{label}: dirty targets require high risk tier") + if lease.get("requires_minimal_hunks") is not True: + errors.append(f"{label}: dirty targets require minimal hunks") + if isinstance(lease.get("owned_paths"), list) and isinstance(lease.get("read_only_paths"), list): + expected = make_lease_id( + str(path_leases.get("run_id") or ""), + str(lease.get("task_id") or ""), + [str(item) for item in lease.get("owned_paths")], + [str(item) for item in lease.get("read_only_paths")], + ) + if lease.get("lease_id") != expected: + errors.append(f"{label}.lease_id must be deterministic: expected {expected}") + if lease.get("state") in {"active", "proposed"}: + active_tasks.append({"task_id": str(lease.get("task_id")), "owned_paths": lease.get("owned_paths", [])}) + + errors.extend(conflict["reason"] for conflict in detect_owned_path_conflicts(active_tasks)) + conflicts = path_leases.get("conflicts") + if not isinstance(conflicts, list): + errors.append("conflicts must be a list") + else: + for conflict in conflicts: + if isinstance(conflict, dict) and conflict.get("severity", "error") == "error": + errors.append(f"{conflict.get('conflict_id', 'conflict')}: {conflict.get('reason', 'conflict present')}") + if not isinstance(path_leases.get("dependency_gates"), list): + errors.append("dependency_gates must be a list") + if not isinstance(path_leases.get("parallel_groups"), list): + errors.append("parallel_groups must be a list") + if not isinstance(path_leases.get("dirty_targets"), list): + errors.append("dirty_targets must be a list") + if not isinstance(path_leases.get("warnings"), list): + errors.append("warnings must be a list") + if not isinstance(path_leases.get("evidence_pointers"), list): + errors.append("evidence_pointers must be a list") + return sorted(dict.fromkeys(errors)) + + +def validate_planned_operations(path_leases: dict[str, Any], operations: dict[str, Any]) -> list[str]: + """Reject unowned writes, unsafe deletes, unowned renames, binary patches, lockfile violations, broad cleanup.""" + errors: list[str] = [] + if operations.get("schema_version") != CURRENT_SCHEMA_VERSION: + errors.append(f"planned-operations.schema_version must be {CURRENT_SCHEMA_VERSION}") + if operations.get("artifact_type") != "planned-operations": + errors.append("planned-operations.artifact_type must be planned-operations") + leases_by_task = { + str(lease.get("task_id")): lease + for lease in path_leases.get("leases", []) + if isinstance(lease, dict) and lease.get("state") in {"active", "proposed", "released"} + } + raw_operations = operations.get("operations") + if not isinstance(raw_operations, list): + return errors + ["planned-operations.operations must be a list"] + for index, operation in enumerate(raw_operations, start=1): + label = f"operations[{index}]" + if not isinstance(operation, dict): + errors.append(f"{label} must be an object") + continue + task_id = str(operation.get("task_id") or "") + lease = leases_by_task.get(task_id) + if not lease: + errors.append(f"{label}: no active lease for task {task_id}") + continue + owned_paths = [str(path) for path in lease.get("owned_paths", [])] + for field in ["changed_paths", "created_paths", "deleted_paths", "binary_paths", "lockfile_paths"]: + if field in operation and not isinstance(operation[field], list): + errors.append(f"{label}.{field} must be a list") + for field in ["changed_paths", "created_paths", "deleted_paths"]: + for raw_path in operation.get(field, []) or []: + try: + path = normalize_repo_path(str(raw_path)) + except LeaseValidationError as exc: + errors.append(f"{label}.{field}: {exc}") + continue + if not path_is_owned(path, owned_paths): + errors.append(f"{label}.{field}: {path} is outside owned paths for {task_id}") + if path in {".", "*", "**"}: + errors.append(f"{label}.{field}: broad cleanup path is rejected: {path}") + for raw_path in operation.get("deleted_paths", []) or []: + try: + path = normalize_repo_path(str(raw_path)) + except LeaseValidationError: + continue + if not path_is_owned(path, owned_paths) or "delete" not in lease.get("allowed_operations", []): + errors.append(f"{label}.deleted_paths: unsafe delete is rejected: {path}") + for raw_path in operation.get("binary_paths", []) or []: + errors.append(f"{label}.binary_paths: binary patch is rejected: {raw_path}") + renames = operation.get("renames", []) or [] + if not isinstance(renames, list): + errors.append(f"{label}.renames must be a list") + else: + for rename_index, rename in enumerate(renames, start=1): + if not isinstance(rename, dict): + errors.append(f"{label}.renames[{rename_index}] must be an object") + continue + for side in ["from", "to"]: + try: + path = normalize_repo_path(str(rename.get(side) or "")) + except LeaseValidationError as exc: + errors.append(f"{label}.renames[{rename_index}].{side}: {exc}") + continue + if not path_is_owned(path, owned_paths): + errors.append(f"{label}.renames[{rename_index}].{side}: unowned rename path is rejected: {path}") + for raw_path in operation.get("lockfile_paths", []) or []: + try: + path = normalize_repo_path(str(raw_path)) + except LeaseValidationError as exc: + errors.append(f"{label}.lockfile_paths: {exc}") + continue + if not is_lockfile_path(path): + errors.append(f"{label}.lockfile_paths: {path} is not a known lockfile path") + if not path_is_owned(path, owned_paths): + errors.append(f"{label}.lockfile_paths: lockfile change outside owned paths is rejected: {path}") + if not lease.get("contract_allows_lockfile"): + errors.append(f"{label}.lockfile_paths: lockfile change requires explicit task contract validation: {path}") + for raw_path in operation.get("broad_cleanup_paths", []) or []: + errors.append(f"{label}.broad_cleanup_paths: broad cleanup is rejected: {raw_path}") + return sorted(dict.fromkeys(errors)) + + +def write_conflict_report(run_dir: Path, path_leases: dict[str, Any]) -> Path: + """Write lease-conflict-report.md.""" + path = run_dir / "lease-conflict-report.md" + lines = [ + metadata_comment("lease-conflict-report", str(path_leases.get("run_id", "")), str(path_leases.get("created_at", utc_now()))), + f"# Patch Swarm Lease Conflict Report: {path_leases.get('run_id', '')}", + "", + "## Summary", + f"- Conflicts: {len(path_leases.get('conflicts', []))}", + f"- Dirty targets: {len(path_leases.get('dirty_targets', []))}", + "", + "## Conflicts", + ] + conflicts = path_leases.get("conflicts", []) + if conflicts: + for conflict in conflicts: + lines.extend( + [ + f"- `{conflict.get('conflict_id')}` `{conflict.get('type')}`: {conflict.get('reason')}", + f" - Tasks: {', '.join(conflict.get('task_ids', [])) or 'n/a'}", + f" - Paths: {', '.join(conflict.get('paths', [])) or 'n/a'}", + f" - Resolution: {conflict.get('resolution')}", + ] + ) + else: + lines.append("- No conflicts in the valid lease artifact.") + lines.extend(["", "## Dirty Targets"]) + for target in path_leases.get("dirty_targets", []) or []: + lines.append(f"- `{target.get('path')}` for {', '.join(target.get('task_ids', []))}: {target.get('required_handling')}") + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + return path + + +def write_validation_report(run_dir: Path, validation: dict[str, Any]) -> Path: + path = run_dir / "lease-validation-report.md" + lines = [ + metadata_comment("lease-validation-report", str(validation.get("run_id", "")), utc_now()), + "# Patch Swarm Lease Validation Report", + "", + "## Summary", + f"- OK: {validation.get('ok')}", + f"- Errors: {len(validation.get('errors', []))}", + f"- Warnings: {len(validation.get('warnings', []))}", + "", + "## Errors", + *([f"- {error}" for error in validation.get("errors", [])] or ["- None"]), + "", + "## Warnings", + *([f"- {warning}" for warning in validation.get("warnings", [])] or ["- None"]), + "", + "## Evidence", + "- `path-leases.json`", + "- `lease-conflicts.json`", + "- `planned-operations.json`", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def write_workset_manifest_if_supported(run_dir: Path, path_leases: dict[str, Any], discovered_format: dict[str, Any] | None = None) -> Path: + """Write workset-manifest.json or workset-compatibility.json.""" + workset_tasks = [] + for lease in path_leases.get("leases", []): + if not isinstance(lease, dict): + continue + if lease.get("state") != "active" or lease.get("requires_manual_review"): + continue + workset_tasks.append( + { + "id": lease["task_id"], + "task": f"Patch Swarm lease {lease['task_id']}", + "write_paths": lease.get("owned_paths", []), + "read_paths": lease.get("read_only_paths", []), + "depends_on": lease.get("dependencies", []), + } + ) + compatibility = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "workset-compatibility", + "run_id": path_leases.get("run_id"), + "created_at": path_leases.get("created_at"), + "provenance": provenance("patch-swarm leases", "workset-compatibility"), + "cento_workset_check_supported": bool(cento_workset and workset_tasks), + "workset_manifest_format": "cento.workset.v1" if cento_workset else None, + "discovered_command": "cento workset check WORKSET --allow-creates --json" if cento_workset else "", + "common_manifest_flag_supported": False, + "reason": ( + "Generated Workset v1 manifest for automatable active leases only; guarded/manual leases stay in path-leases.json." + if cento_workset and workset_tasks + else "No compatible automatable Workset manifest subset discovered" + ), + "path_leases": "path-leases.json", + "evidence_pointers": [], + } + compat_path = run_dir / "workset-compatibility.json" + write_json(compat_path, compatibility) + if cento_workset and workset_tasks: + manifest = { + "schema_version": "cento.workset.v1", + "id": f"patch_swarm_{path_leases.get('run_id')}", + "mode": "fast", + "max_parallel": max(1, min(5, len(workset_tasks))), + "tasks": workset_tasks, + } + manifest_path = run_dir / "workset-manifest.json" + write_json(manifest_path, manifest) + path_leases["workset_manifest"] = "workset-manifest.json" + write_json(run_dir / "path-leases.json", path_leases) + return manifest_path + path_leases["workset_manifest"] = None + write_json(run_dir / "path-leases.json", path_leases) + return compat_path + + +def validate_workset_compatibility(run_dir: Path) -> dict[str, Any]: + """Run or prepare cento workset check compatibility if possible.""" + manifest_path = run_dir / "workset-manifest.json" + if not manifest_path.exists(): + return { + "ok": True, + "status": "skipped", + "reason": "No workset-manifest.json generated; see workset-compatibility.json.", + } + if cento_workset is None: + return {"ok": False, "status": "unavailable", "reason": "cento_workset import unavailable"} + manifest = read_json(manifest_path) + result = cento_workset.validate_workset(manifest, allow_missing_write_paths=True) + command = ["cento", "workset", "check", rel(manifest_path), "--allow-creates", "--json"] + return { + "ok": result.get("status") == "passed", + "status": result.get("status"), + "command": command, + "errors": result.get("errors", []), + "warnings": result.get("warnings", []), + } + + +def validate_run_directory(run_dir: Path) -> dict[str, Any]: + path = run_dir / "path-leases.json" + payload = read_json(path) + errors = validate_path_leases(payload) + warnings = [str(item.get("message", item)) for item in payload.get("warnings", []) if isinstance(item, dict)] + report = { + "ok": not errors, + "run_id": payload.get("run_id", ""), + "run_dir": rel(run_dir), + "path_leases": rel(path), + "checked_artifacts": ["path-leases.json"], + "errors": errors, + "warnings": warnings, + "workset_compatibility": validate_workset_compatibility(run_dir), + } + return report + + +def _fixture_split_plan(run_id: str, timestamp: str) -> dict[str, Any]: + tasks = [ + { + "task_id": "task-0001", + "title": "Docs-only lease fixture A", + "story": "As an operator, I need one docs-only fixture task.", + "summary": "Write the first non-conflicting docs evidence file.", + "lane": "docs-evidence", + "state": "created", + "risk_tier": "low", + "human_handoff": False, + "worker_profile": "docs-evidence-writer", + "owned_paths": ["workspace/runs/parallel-delivery/lease-fixture/task-work/docs-task-a.md"], + "read_only_paths": ["docs/patch-swarm.md"], + "dependencies": [], + "acceptance_contract": ["Only the owned fixture docs file is changed."], + "validation_commands": ["python3 -m json.tool workspace/runs/parallel-delivery/lease-fixture/path-leases.json >/dev/null"], + "expected_artifacts": ["task-work/docs-task-a.md"], + "integration_notes": ["Safe to run with other docs-only task."], + "rejection_triggers": ["Touches unowned paths."], + "evidence_pointers": [], + }, + { + "task_id": "task-0002", + "title": "Docs-only lease fixture B", + "story": "As an operator, I need a second docs-only fixture task.", + "summary": "Write the second non-conflicting docs evidence file.", + "lane": "docs-evidence", + "state": "created", + "risk_tier": "low", + "human_handoff": False, + "worker_profile": "docs-evidence-writer", + "owned_paths": ["workspace/runs/parallel-delivery/lease-fixture/task-work/docs-task-b.md"], + "read_only_paths": ["docs/patch-swarm.md"], + "dependencies": [], + "acceptance_contract": ["Only the owned fixture docs file is changed."], + "validation_commands": ["python3 -m json.tool workspace/runs/parallel-delivery/lease-fixture/path-leases.json >/dev/null"], + "expected_artifacts": ["task-work/docs-task-b.md"], + "integration_notes": ["Shares read-only context with task-0001."], + "rejection_triggers": ["Touches unowned paths."], + "evidence_pointers": [], + }, + { + "task_id": "task-0003", + "title": "Dependency-gated validation fixture", + "story": "As an operator, I need a task that waits for docs output.", + "summary": "Validate evidence produced by task-0001.", + "lane": "validator", + "state": "created", + "risk_tier": "medium", + "human_handoff": False, + "worker_profile": "test-writer", + "owned_paths": ["workspace/runs/parallel-delivery/lease-fixture/task-work/validation-task-0003.json"], + "read_only_paths": ["workspace/runs/parallel-delivery/lease-fixture/task-work/docs-task-a.md"], + "dependencies": ["task-0001"], + "acceptance_contract": ["Validation evidence references task-0001 output without editing it."], + "validation_commands": ["python3 -m json.tool workspace/runs/parallel-delivery/lease-fixture/lease-validation.json >/dev/null"], + "expected_artifacts": ["task-work/validation-task-0003.json"], + "integration_notes": ["Runs after task-0001."], + "rejection_triggers": ["Runs in the same parallel group as task-0001."], + "evidence_pointers": [], + }, + { + "task_id": "task-0004", + "title": "Guarded registry path fixture", + "story": "As an operator, I need guarded registry writes to be high risk.", + "summary": "Declare a guarded registry path and require manual review.", + "lane": "coordinator", + "state": "created", + "risk_tier": "medium", + "human_handoff": False, + "worker_profile": "factory-planner", + "owned_paths": ["data/tools.json"], + "read_only_paths": ["docs/parallel-delivery/patch-swarm-planner.md"], + "dependencies": [], + "acceptance_contract": ["Registry edits are explicitly owned, high risk, manually reviewed, and minimal-hunk only."], + "validation_commands": ["python3 -m json.tool data/tools.json >/dev/null"], + "expected_artifacts": ["data/tools.json"], + "integration_notes": ["Manual review gate required."], + "rejection_triggers": ["Touches another registry path."], + "evidence_pointers": [], + }, + { + "task_id": "task-0005", + "title": "Explicit lockfile contract fixture", + "story": "As an operator, I need lockfile writes to require dependency validation.", + "summary": "Declare an explicit lockfile package dependency validation contract.", + "lane": "builder", + "state": "created", + "risk_tier": "medium", + "human_handoff": False, + "worker_profile": "cli-builder", + "owned_paths": ["package-lock.json"], + "read_only_paths": ["docs/cento-build.md"], + "dependencies": [], + "acceptance_contract": ["Lockfile package dependency validation is explicitly required before integration."], + "validation_commands": ["echo lockfile package dependency validation evidence"], + "expected_artifacts": ["package-lock.json"], + "integration_notes": ["Manual review gate required for lockfile changes."], + "rejection_triggers": ["Lockfile change lacks validation evidence."], + "evidence_pointers": [], + }, + ] + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "split-plan", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": provenance("write-fixture", "fixture"), + "max_candidate_tasks": 5, + "candidate_target": 5, + "candidate_count": 5, + "max_parallel_agents": 3, + "planner_mode": "fixture", + "planning_policy": {"read_many_write_few": True, "avoid_overlapping_owned_paths": True}, + "lanes": ["docs-evidence", "validator", "coordinator", "builder"], + "tasks": tasks, + "evidence_pointers": [], + } + + +def _fixture_task_graph(split_plan: dict[str, Any]) -> dict[str, Any]: + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "task-graph", + "run_id": split_plan["run_id"], + "created_at": split_plan["created_at"], + "updated_at": split_plan["updated_at"], + "provenance": split_plan["provenance"], + "nodes": [ + { + "task_id": task["task_id"], + "lane": task["lane"], + "risk_tier": task["risk_tier"], + "owned_paths": task["owned_paths"], + "human_handoff": task["human_handoff"], + } + for task in split_plan["tasks"] + ], + "edges": [ + { + "from": "task-0001", + "to": "task-0003", + "type": "depends_on", + "reason": "task-0003 validates task-0001 output", + } + ], + "topological_order": ["task-0001", "task-0002", "task-0003", "task-0004", "task-0005"], + "parallel_groups": [], + "max_parallel_agents": 3, + "evidence_pointers": [], + } + + +def _planned_operations(run_id: str, timestamp: str) -> dict[str, Any]: + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "planned-operations", + "run_id": run_id, + "created_at": timestamp, + "provenance": provenance("write-fixture", "fixture"), + "operations": [ + { + "task_id": "task-0001", + "changed_paths": ["workspace/runs/parallel-delivery/lease-fixture/task-work/docs-task-a.md"], + "created_paths": [], + "deleted_paths": [], + "renames": [], + "binary_paths": [], + "lockfile_paths": [], + }, + { + "task_id": "task-0002", + "changed_paths": ["workspace/runs/parallel-delivery/lease-fixture/task-work/docs-task-b.md"], + "created_paths": [], + "deleted_paths": [], + "renames": [], + "binary_paths": [], + "lockfile_paths": [], + }, + { + "task_id": "task-0004", + "changed_paths": ["data/tools.json"], + "created_paths": [], + "deleted_paths": [], + "renames": [], + "binary_paths": [], + "lockfile_paths": [], + }, + { + "task_id": "task-0005", + "changed_paths": ["package-lock.json"], + "created_paths": [], + "deleted_paths": [], + "renames": [], + "binary_paths": [], + "lockfile_paths": ["package-lock.json"], + }, + ], + "evidence_pointers": [], + } + + +def _conflict_example(base: dict[str, Any], name: str) -> dict[str, Any]: + payload = copy.deepcopy(base) + payload["run_id"] = f"lease-fixture-{name}" + payload["conflicts"] = [] + for lease in payload.get("leases", []): + lease["lease_id"] = make_lease_id(payload["run_id"], lease["task_id"], lease["owned_paths"], lease["read_only_paths"]) + if name == "exact-overlap": + payload["leases"][1]["owned_paths"] = list(payload["leases"][0]["owned_paths"]) + payload["leases"][1]["lease_id"] = make_lease_id( + payload["run_id"], payload["leases"][1]["task_id"], payload["leases"][1]["owned_paths"], payload["leases"][1]["read_only_paths"] + ) + elif name == "parent-child-overlap": + payload["leases"][0]["owned_paths"] = ["workspace/runs/parallel-delivery/lease-fixture/conflict-parent"] + payload["leases"][1]["owned_paths"] = ["workspace/runs/parallel-delivery/lease-fixture/conflict-parent/child.md"] + for lease in payload["leases"][:2]: + lease["lease_id"] = make_lease_id(payload["run_id"], lease["task_id"], lease["owned_paths"], lease["read_only_paths"]) + elif name == "protected-path": + payload["leases"][0]["owned_paths"] = [".env.mcp"] + payload["leases"][0]["protected_paths"] = [".env.mcp"] + else: + reasons = { + "unsafe-delete": "unsafe delete outside owned paths is rejected", + "unowned-rename": "unowned rename is rejected", + "binary-patch": "binary patch metadata is rejected", + "broad-cleanup": "broad cleanup path is rejected", + "lockfile-outside-contract": "lockfile change outside explicit contract is rejected", + } + payload["conflicts"].append( + _conflict( + 1, + name.replace("-", "_"), + ["task-0001"], + ["docs/unowned.md"], + reasons[name], + resolution="fix planned operation metadata before integration", + ) + ) + return payload + + +def build_lease_fixture(run_dir: Path, *, run_id: str, timestamp: str) -> dict[str, Any]: + """Generate deterministic valid lease fixture plus conflict examples.""" + run_dir.mkdir(parents=True, exist_ok=True) + request_text = "\n".join( + [ + metadata_comment("request", run_id, timestamp), + "# Patch Swarm Lease Fixture Request", + "", + "Generate deterministic path leases for docs-only tasks, dependency gates, guarded paths, lockfiles, and conflict examples.", + "", + ] + ) + (run_dir / "request.md").write_text(request_text, encoding="utf-8") + split_plan = _fixture_split_plan(run_id, timestamp) + task_graph = _fixture_task_graph(split_plan) + write_json(run_dir / "split-plan.json", split_plan) + write_json(run_dir / "task-graph.json", task_graph) + git_status = subprocess.run( + ["git", "status", "--porcelain=v1"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ).stdout + leases = create_leases(split_plan, task_graph, git_status_text=git_status, timestamp=timestamp, command="write-fixture") + write_json(run_dir / "path-leases.json", leases) + write_workset_manifest_if_supported(run_dir, leases) + validation = validate_run_directory(run_dir) + write_json(run_dir / "lease-validation.json", validation) + write_validation_report(run_dir, validation) + write_json(run_dir / "planned-operations.json", _planned_operations(run_id, timestamp)) + + conflict_examples = {} + examples_dir = run_dir / "conflict-examples" + examples_dir.mkdir(parents=True, exist_ok=True) + for name in [ + "exact-overlap", + "parent-child-overlap", + "protected-path", + "unsafe-delete", + "unowned-rename", + "binary-patch", + "broad-cleanup", + "lockfile-outside-contract", + ]: + example = _conflict_example(leases, name) + conflict_examples[name] = {"path": f"conflict-examples/{name}.json", "conflicts": example.get("conflicts", [])} + write_json(examples_dir / f"{name}.json", example) + write_json( + run_dir / "lease-conflicts.json", + { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "lease-conflicts", + "run_id": run_id, + "created_at": timestamp, + "provenance": provenance("write-fixture", "fixture"), + "conflict_examples": conflict_examples, + "evidence_pointers": [], + }, + ) + write_conflict_report(run_dir, leases) + start_here = "\n".join( + [ + metadata_comment("start-here", run_id, timestamp), + f"# Patch Swarm Lease Run: {run_id}", + "", + "## What This Is", + "A deterministic fixture for Patch Swarm path leasing and Workset compatibility.", + "", + "## Artifact Index", + "- `split-plan.json`", + "- `task-graph.json`", + "- `path-leases.json`", + "- `lease-conflicts.json`", + "- `lease-validation.json`", + "- `planned-operations.json`", + "", + "## Validation Result", + f"`ok={validation['ok']}`", + "", + "## Next Operator Action", + "Inspect `lease-validation-report.md` and conflict examples before enabling prompt emission.", + "", + ] + ) + (run_dir / "start-here.md").write_text(start_here, encoding="utf-8") + return { + "ok": validation["ok"], + "run_id": run_id, + "run_dir": rel(run_dir), + "path_leases": rel(run_dir / "path-leases.json"), + "lease_validation": rel(run_dir / "lease-validation.json"), + "conflict_examples": sorted(conflict_examples), + "errors": validation["errors"], + "warnings": validation["warnings"], + } + + +def create_from_files( + run_dir: Path, + split_plan_path: Path, + task_graph_path: Path | None, + *, + timestamp: str | None = None, + command: str = "patch-swarm leases", +) -> dict[str, Any]: + split_plan = read_json(split_plan_path) + task_graph = read_json(task_graph_path) if task_graph_path and task_graph_path.exists() else None + git_status = subprocess.run( + ["git", "status", "--porcelain=v1"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ).stdout + payload = create_leases(split_plan, task_graph, git_status_text=git_status, timestamp=timestamp or utc_now(), command=command) + run_dir.mkdir(parents=True, exist_ok=True) + write_json(run_dir / "path-leases.json", payload) + write_workset_manifest_if_supported(run_dir, payload) + write_json(run_dir / "lease-conflicts.json", {"schema_version": 1, "artifact_type": "lease-conflicts", "run_id": payload["run_id"], "created_at": payload["created_at"], "provenance": payload["provenance"], "conflicts": payload["conflicts"], "evidence_pointers": []}) + write_conflict_report(run_dir, payload) + validation = validate_run_directory(run_dir) + write_json(run_dir / "lease-validation.json", validation) + write_validation_report(run_dir, validation) + return { + "ok": not validation["errors"], + "run_id": payload["run_id"], + "run_dir": rel(run_dir), + "path_leases": rel(run_dir / "path-leases.json"), + "errors": validation["errors"], + "warnings": validation["warnings"], + } + + +def print_policy() -> dict[str, Any]: + """Return lease policy for CLI/test validation.""" + return lease_policy() + + +def add_write_fixture_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-dir", required=True) + parser.add_argument("--run-id", default="lease-fixture") + parser.add_argument("--fixed-timestamp", default="") + parser.add_argument("--json", action="store_true") + + +def add_create_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-dir", required=True) + parser.add_argument("--split-plan", default="") + parser.add_argument("--task-graph", default="") + parser.add_argument("--run-id", default="") + parser.add_argument("--fixture", action="store_true") + parser.add_argument("--fixed-timestamp", default="") + parser.add_argument("--json", action="store_true") + + +def add_validate_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-dir", default="") + parser.add_argument("--path-leases", default="") + parser.add_argument("--json", action="store_true") + + +def add_check_operations_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-dir", required=True) + parser.add_argument("--operations", required=True) + parser.add_argument("--json", action="store_true") + + +def run_write_fixture(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + timestamp = getattr(args, "fixed_timestamp", "") or utc_now() + payload = build_lease_fixture(Path(args.run_dir), run_id=getattr(args, "run_id", "lease-fixture"), timestamp=timestamp) + return payload, 0 if payload.get("ok") else 1 + + +def run_create(args: argparse.Namespace, *, command: str = "patch-swarm leases") -> tuple[dict[str, Any], int]: + timestamp = getattr(args, "fixed_timestamp", "") or utc_now() + run_dir = Path(args.run_dir) + if getattr(args, "fixture", False): + payload = build_lease_fixture(run_dir, run_id=getattr(args, "run_id", "") or "lease-fixture", timestamp=timestamp) + return payload, 0 if payload.get("ok") else 1 + split_plan = Path(getattr(args, "split_plan", "") or run_dir / "split-plan.json") + task_graph_value = getattr(args, "task_graph", "") or str(run_dir / "task-graph.json") + task_graph = Path(task_graph_value) if task_graph_value else None + payload = create_from_files(run_dir, split_plan, task_graph, timestamp=timestamp, command=command) + return payload, 0 if payload.get("ok") else 1 + + +def run_validate(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + if getattr(args, "path_leases", ""): + path = Path(args.path_leases) + payload = read_json(path) + errors = validate_path_leases(payload) + result = { + "ok": not errors, + "run_id": payload.get("run_id", ""), + "path_leases": rel(path), + "checked_artifacts": [rel(path)], + "errors": errors, + "warnings": [], + } + return result, 0 if result["ok"] else 1 + run_dir = Path(getattr(args, "run_dir", "") or ".") + result = validate_run_directory(run_dir) + return result, 0 if result["ok"] else 1 + + +def run_check_operations(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + run_dir = Path(args.run_dir) + path_leases = read_json(run_dir / "path-leases.json") + operations = read_json(Path(args.operations)) + errors = validate_planned_operations(path_leases, operations) + payload = { + "ok": not errors, + "run_id": path_leases.get("run_id", ""), + "run_dir": rel(run_dir), + "operations": rel(Path(args.operations)), + "errors": errors, + "warnings": [], + } + return payload, 0 if payload["ok"] else 1 + + +def print_payload(payload: dict[str, Any], *, as_json: bool) -> None: + if as_json: + print(stable_json_dumps(payload), end="") + else: + print("ok" if payload.get("ok", True) else "failed") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create and validate Patch Swarm path leases.") + sub = parser.add_subparsers(dest="command", required=True) + + write_fixture = sub.add_parser("write-fixture", help="Write a deterministic lease fixture run.") + add_write_fixture_args(write_fixture) + + create = sub.add_parser("create", help="Create path-leases.json from split-plan/task-graph.") + add_create_args(create) + + validate = sub.add_parser("validate", help="Validate a path-leases.json artifact or run directory.") + add_validate_args(validate) + + check_operations = sub.add_parser("check-operations", help="Validate planned patch operation metadata against leases.") + add_check_operations_args(check_operations) + + policy = sub.add_parser("print-policy", help="Print Patch Swarm lease policy.") + policy.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + if args.command == "write-fixture": + payload, code = run_write_fixture(args) + elif args.command == "create": + payload, code = run_create(args) + elif args.command == "validate": + payload, code = run_validate(args) + elif args.command == "check-operations": + payload, code = run_check_operations(args) + elif args.command == "print-policy": + payload, code = print_policy(), 0 + else: # pragma: no cover + parser.error(f"unknown command: {args.command}") + print_payload(payload, as_json=bool(getattr(args, "json", False))) + return code + except LeaseValidationError as exc: + payload = {"ok": False, "errors": [str(exc)], "warnings": []} + print_payload(payload, as_json=bool(getattr(args, "json", False))) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_patch_bundles.py b/scripts/parallel_delivery_patch_bundles.py new file mode 100644 index 0000000..b485315 --- /dev/null +++ b/scripts/parallel_delivery_patch_bundles.py @@ -0,0 +1,1072 @@ +#!/usr/bin/env python3 +"""Local Patch Swarm patch bundle collection and safety validation.""" + +from __future__ import annotations + +import argparse +from collections import Counter +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import fnmatch +import hashlib +import json +import re +import shlex +import sys +from pathlib import Path, PurePosixPath +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import cento_build as build_safety # noqa: E402 + + +SCHEMA_PATCH_BUNDLE = "cento.patch_bundle.v1" +SCHEMA_LEASES = "cento.patch_bundle_leases.v1" +SCHEMA_RECEIPT = "cento.patch_bundle_receipt.v1" +SCHEMA_REPORT = "cento.patch_bundle_collection_report.v1" +VALIDATOR_VERSION = "patch-bundle-validator-v1" +DEFAULT_TIMESTAMP = "2026-01-01T00:00:00Z" + +REASON_CODES = [ + "missing_required_field", + "invalid_bundle_schema", + "run_id_mismatch", + "base_commit_mismatch", + "missing_task_lease", + "unsafe_path_traversal", + "absolute_path", + "path_outside_lease", + "diff_path_not_declared", + "declared_path_not_in_diff", + "protected_path_edit", + "local_secret_path_edit", + "symlink_patch_prohibited", + "submodule_patch_prohibited", + "binary_patch_prohibited", + "undeclared_delete", + "unowned_rename", + "broad_lockfile_change", + "secret_like_content", + "unsafe_evidence_path", + "missing_evidence_file", + "unsupported_patch_ref", + "worker_validation_missing", + "worker_validation_failed", +] + +DEFAULT_PROTECTED_PATHS = [ + ".env", + ".env.*", + ".env.mcp", + "**/.env", + "**/.env.*", + "*.pem", + "*.key", + "*secret*", + "*token*", + "*credential*", +] + +REMOTE_REF_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://") +WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]") +SECRET_LINE_RE = re.compile( + r"(?i)(api[_-]?key|secret|token|credential|password|private[_-]?key|sk-[a-z0-9_-]{8,})" +) + + +class BundleValidationError(RuntimeError): + """Expected patch bundle validation failure.""" + + +class PathValidationError(BundleValidationError): + """Path validation failure with a stable reason code.""" + + def __init__(self, code: str, detail: str) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + + +@dataclass(frozen=True) +class PatchBundle: + schema: str + bundle_id: str + task_id: str + worker_id: str + run_id: str + base_commit: str + touched_paths: list[str] + diff_path: str | None + patch_content_ref: dict[str, Any] | str | None + changed_file_summary: list[dict[str, Any]] + validation_commands: list[dict[str, Any]] + evidence_files: list[str] + result_status: str + risk_flags: list[str] + + +@dataclass(frozen=True) +class LeaseSpec: + task_id: str + allowed_paths: list[str] + protected_paths: list[str] + allowed_deletes: list[str] + allowed_renames: list[dict[str, str]] + allowed_lockfiles: list[str] + allow_binary: bool = False + allow_symlinks: bool = False + allow_submodules: bool = False + max_lockfile_changed_lines: int = 100 + + +@dataclass(frozen=True) +class ValidationIssue: + code: str + path: str | None + detail: str + + +@dataclass(frozen=True) +class DiffSummary: + paths: list[str] + path_errors: list[ValidationIssue] + delete_paths: list[str] + renames: list[dict[str, str]] + binary: bool + symlink_paths: list[str] + submodule_paths: list[str] + lockfile_line_deltas: dict[str, int] + secret_like_added_paths: list[str] + + +@dataclass(frozen=True) +class BundleReceipt: + schema: str + receipt_id: str + bundle_id: str + task_id: str + worker_id: str + run_id: str + base_commit: str + validation_status: str + integratable: bool + reason_codes: list[str] + issues: list[ValidationIssue] + normalized_touched_paths: list[str] + diff_paths: list[str] + changed_file_summary: list[dict[str, Any]] + worker_validation_commands: list[dict[str, Any]] + evidence_files: list[str] + risk_flags: list[str] + patch_sha256: str | None + validator_version: str + validated_at: str + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json_dumps(payload: Any) -> str: + return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json_dumps(payload), encoding="utf-8") + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise BundleValidationError(f"file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise BundleValidationError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(payload, dict): + raise BundleValidationError(f"expected JSON object in {path}") + return payload + + +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def safe_id(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in value.strip()) + return cleaned.strip("-") or "unknown" + + +def _path_code(raw: str) -> str: + return "absolute_path" if raw.startswith("/") or WINDOWS_DRIVE_RE.match(raw) else "unsafe_path_traversal" + + +def normalize_repo_relative_path(raw: str) -> str: + """Return a normalized POSIX repo-relative path or raise a coded error.""" + + if not isinstance(raw, str): + raise PathValidationError("unsafe_path_traversal", "path must be a string") + if "\x00" in raw: + raise PathValidationError("unsafe_path_traversal", "NUL byte is not allowed in paths") + value = raw.strip() + if not value: + raise PathValidationError("unsafe_path_traversal", "empty path is not allowed") + if REMOTE_REF_RE.match(value): + raise PathValidationError("unsupported_patch_ref", "remote patch or artifact refs are not supported") + if WINDOWS_DRIVE_RE.match(value): + raise PathValidationError("absolute_path", "Windows drive paths are not allowed") + value = value.replace("\\", "/") + while value.startswith("./"): + value = value[2:] + if value.startswith("/") or Path(value).is_absolute(): + raise PathValidationError("absolute_path", "absolute paths are not allowed") + while "//" in value: + value = value.replace("//", "/") + value = value.rstrip("/") if value != "." else value + if value in {"", ".", "*", "**", "./"}: + raise PathValidationError("unsafe_path_traversal", "repo-root or broad cleanup path is not allowed") + parts = PurePosixPath(value).parts + if ".." in parts: + raise PathValidationError("unsafe_path_traversal", "path traversal is not allowed") + if parts and parts[0] == ".git": + raise PathValidationError("unsafe_path_traversal", "git metadata paths are not allowed") + return PurePosixPath(value).as_posix() + + +def normalize_diff_path(raw: str) -> str | None: + value = raw.strip() + if value in {"/dev/null", "dev/null"}: + return None + try: + parts = shlex.split(value) + if parts: + value = parts[0] + except ValueError: + value = value.split("\t", 1)[0].split(" ", 1)[0] + if value.startswith("a/") or value.startswith("b/"): + value = value[2:] + return normalize_repo_relative_path(value) + + +def path_matches(path: str, pattern: str) -> bool: + if pattern.endswith("/**"): + prefix = pattern[:-3].rstrip("/") + return path == prefix or path.startswith(prefix + "/") + if any(ch in pattern for ch in "*?["): + return fnmatch.fnmatch(path, pattern) + return path == pattern or path.startswith(pattern.rstrip("/") + "/") + + +def path_allowed(path: str, patterns: list[str]) -> bool: + return any(path_matches(path, pattern) for pattern in patterns) + + +def is_local_secret_path(path: str) -> bool: + lowered = path.lower() + name = PurePosixPath(path).name.lower() + return ( + name == ".env" + or name.startswith(".env.") + or name == ".env.mcp" + or lowered.endswith(".pem") + or lowered.endswith(".key") + or "secret" in lowered + or "token" in lowered + or "credential" in lowered + ) + + +def is_protected_path(path: str, protected_patterns: list[str]) -> bool: + if is_local_secret_path(path): + return True + for pattern in protected_patterns: + try: + normalized_pattern = normalize_repo_relative_path(pattern) + except PathValidationError: + normalized_pattern = pattern.replace("\\", "/") + if path_matches(path, normalized_pattern): + return True + if "/" not in normalized_pattern and fnmatch.fnmatch(PurePosixPath(path).name, normalized_pattern): + return True + return False + + +def normalize_optional_path_list(values: Any) -> tuple[list[str], list[ValidationIssue]]: + issues: list[ValidationIssue] = [] + result: list[str] = [] + seen: set[str] = set() + if values is None: + return [], [] + if not isinstance(values, list): + return [], [ValidationIssue("missing_required_field", None, "path list must be an array")] + for value in values: + try: + normalized = normalize_repo_relative_path(str(value)) + except PathValidationError as exc: + issues.append(ValidationIssue(exc.code, str(value), exc.detail)) + continue + if normalized not in seen: + seen.add(normalized) + result.append(normalized) + return sorted(result), issues + + +def bundle_from_payload(payload: dict[str, Any]) -> tuple[PatchBundle | None, list[ValidationIssue]]: + issues: list[ValidationIssue] = [] + required = [ + "schema", + "bundle_id", + "task_id", + "worker_id", + "run_id", + "base_commit", + "touched_paths", + "changed_file_summary", + "validation_commands", + "evidence_files", + "result_status", + "risk_flags", + ] + for field in required: + if field not in payload: + issues.append(ValidationIssue("missing_required_field", None, f"{field} is required")) + if payload.get("schema") != SCHEMA_PATCH_BUNDLE: + issues.append(ValidationIssue("invalid_bundle_schema", None, f"schema must be {SCHEMA_PATCH_BUNDLE}")) + for field in ("changed_file_summary", "validation_commands", "evidence_files", "risk_flags", "touched_paths"): + if field in payload and not isinstance(payload.get(field), list): + issues.append(ValidationIssue("missing_required_field", None, f"{field} must be an array")) + if issues: + return None, issues + return ( + PatchBundle( + schema=str(payload["schema"]), + bundle_id=str(payload["bundle_id"]), + task_id=str(payload["task_id"]), + worker_id=str(payload["worker_id"]), + run_id=str(payload["run_id"]), + base_commit=str(payload["base_commit"]), + touched_paths=[str(item) for item in payload.get("touched_paths") or []], + diff_path=str(payload["diff_path"]) if payload.get("diff_path") else None, + patch_content_ref=payload.get("patch_content_ref"), + changed_file_summary=[dict(item) for item in payload.get("changed_file_summary") or [] if isinstance(item, dict)], + validation_commands=[dict(item) for item in payload.get("validation_commands") or [] if isinstance(item, dict)], + evidence_files=[str(item) for item in payload.get("evidence_files") or []], + result_status=str(payload.get("result_status") or ""), + risk_flags=[str(item) for item in payload.get("risk_flags") or []], + ), + [], + ) + + +def load_lease_manifest(path: Path) -> tuple[str, str, dict[str, LeaseSpec]]: + payload = read_json(path) + run_id = str(payload.get("run_id") or "") + base_commit = str(payload.get("base_commit") or "") + tasks = payload.get("tasks") + if not isinstance(tasks, dict): + raise BundleValidationError("lease manifest tasks must be an object") + leases: dict[str, LeaseSpec] = {} + for task_id, spec in tasks.items(): + if not isinstance(spec, dict): + continue + allowed, _ = normalize_optional_path_list(spec.get("allowed_paths") or []) + protected, _ = normalize_optional_path_list(spec.get("protected_paths") or DEFAULT_PROTECTED_PATHS) + deletes, _ = normalize_optional_path_list(spec.get("allowed_deletes") or []) + lockfiles, _ = normalize_optional_path_list(spec.get("allowed_lockfiles") or []) + renames = [] + for item in spec.get("allowed_renames") or []: + if not isinstance(item, dict): + continue + try: + renames.append( + { + "from": normalize_repo_relative_path(str(item.get("from") or "")), + "to": normalize_repo_relative_path(str(item.get("to") or "")), + } + ) + except PathValidationError: + continue + leases[str(task_id)] = LeaseSpec( + task_id=str(task_id), + allowed_paths=allowed, + protected_paths=protected or DEFAULT_PROTECTED_PATHS, + allowed_deletes=deletes, + allowed_renames=renames, + allowed_lockfiles=lockfiles, + allow_binary=bool(spec.get("allow_binary", False)), + allow_symlinks=bool(spec.get("allow_symlinks", False)), + allow_submodules=bool(spec.get("allow_submodules", False)), + max_lockfile_changed_lines=int(spec.get("max_lockfile_changed_lines") or 100), + ) + return run_id, base_commit, leases + + +def _resolve_local_ref(raw: str, bundle_dir: Path, run_root: Path) -> Path: + if REMOTE_REF_RE.match(raw.strip()): + raise PathValidationError("unsupported_patch_ref", "remote refs are not supported") + normalized = normalize_repo_relative_path(raw) + root_candidate = run_root / normalized + bundle_candidate = bundle_dir / normalized + if root_candidate.exists(): + return root_candidate + return bundle_candidate + + +def load_patch_text(bundle: PatchBundle, bundle_dir: Path, run_root: Path) -> tuple[str | None, str | None, list[ValidationIssue]]: + if bundle.result_status == "evidence_only": + return None, None, [] + ref: str | None = bundle.diff_path + if bundle.patch_content_ref: + if isinstance(bundle.patch_content_ref, str): + ref = bundle.patch_content_ref + elif isinstance(bundle.patch_content_ref, dict): + ref = str(bundle.patch_content_ref.get("path") or bundle.patch_content_ref.get("file") or "") + else: + return None, None, [ValidationIssue("unsupported_patch_ref", None, "patch_content_ref must be a local path")] + if not ref: + return None, None, [ValidationIssue("missing_required_field", None, "diff_path or local patch_content_ref is required")] + try: + patch_path = _resolve_local_ref(ref, bundle_dir, run_root) + except PathValidationError as exc: + return None, None, [ValidationIssue(exc.code, ref, exc.detail)] + if not patch_path.exists(): + return None, None, [ValidationIssue("unsupported_patch_ref", ref, "local patch ref does not exist")] + text = patch_path.read_text(encoding="utf-8", errors="replace") + return text, sha256_text(text), [] + + +def parse_git_diff(patch_text: str) -> DiffSummary: + paths: set[str] = set() + path_errors: list[ValidationIssue] = [] + delete_paths: set[str] = set() + symlink_paths: set[str] = set() + submodule_paths: set[str] = set() + renames: list[dict[str, str]] = [] + lockfile_line_deltas: Counter[str] = Counter() + secret_like_added_paths: set[str] = set() + current_paths: set[str] = set() + rename_from: str | None = None + binary = False + + def add_path(raw: str) -> str | None: + try: + parsed = normalize_diff_path(raw) + except PathValidationError as exc: + path_errors.append(ValidationIssue(exc.code, raw, exc.detail)) + return None + if parsed: + paths.add(parsed) + current_paths.add(parsed) + return parsed + + for line in patch_text.splitlines(): + if line.startswith("diff --git "): + current_paths = set() + rename_from = None + tail = line[len("diff --git ") :] + try: + parts = shlex.split(tail) + except ValueError: + parts = tail.split() + for raw in parts[:2]: + add_path(raw) + continue + if line.startswith("Binary files") or line.startswith("GIT binary patch"): + binary = True + continue + if line.startswith("rename from "): + rename_from = add_path(line[len("rename from ") :]) + continue + if line.startswith("rename to "): + rename_to = add_path(line[len("rename to ") :]) + if rename_from and rename_to: + renames.append({"from": rename_from, "to": rename_to}) + continue + if line.startswith("deleted file mode "): + delete_paths.update(current_paths) + continue + if line.startswith(("old mode 120000", "new mode 120000", "new file mode 120000", "deleted file mode 120000")): + symlink_paths.update(current_paths) + continue + if line.startswith(("old mode 160000", "new mode 160000", "new file mode 160000", "deleted file mode 160000", "Subproject commit ")): + submodule_paths.update(current_paths) + continue + if line.startswith("--- ") or line.startswith("+++ "): + before = set(current_paths) + parsed = add_path(line[4:]) + if parsed is None and line.startswith("+++ "): + delete_paths.update(before) + continue + if line.startswith("+") and not line.startswith("+++ "): + for path in current_paths: + lockfile_line_deltas[path] += 1 + if SECRET_LINE_RE.search(line[1:]): + secret_like_added_paths.update(current_paths or {""}) + continue + if line.startswith("-") and not line.startswith("--- "): + for path in current_paths: + lockfile_line_deltas[path] += 1 + + return DiffSummary( + paths=sorted(paths), + path_errors=path_errors, + delete_paths=sorted(delete_paths), + renames=renames, + binary=binary, + symlink_paths=sorted(symlink_paths), + submodule_paths=sorted(submodule_paths), + lockfile_line_deltas=dict(sorted(lockfile_line_deltas.items())), + secret_like_added_paths=sorted(secret_like_added_paths), + ) + + +def _issue(code: str, path: str | None, detail: str) -> ValidationIssue: + return ValidationIssue(code, path, detail) + + +def _append_path_safety_issues(paths: list[str], lease: LeaseSpec, issues: list[ValidationIssue]) -> None: + for path in paths: + if is_protected_path(path, lease.protected_paths): + issues.append(_issue("protected_path_edit", path, "Protected path cannot be edited by patch bundle.")) + if is_local_secret_path(path): + issues.append(_issue("local_secret_path_edit", path, "Local secret-looking path cannot be edited by patch bundle.")) + if not path_allowed(path, lease.allowed_paths): + issues.append(_issue("path_outside_lease", path, "Path is outside the authoritative task lease.")) + + +def _append_worker_validation_issues(bundle: PatchBundle, issues: list[ValidationIssue]) -> None: + if bundle.result_status != "patch_ready": + return + if not bundle.validation_commands: + issues.append(_issue("worker_validation_missing", None, "Patch bundle must include worker validation command evidence.")) + return + for command in bundle.validation_commands: + if "exit_code" not in command: + issues.append(_issue("worker_validation_missing", None, "Worker validation command is missing exit_code.")) + elif int(command.get("exit_code") or 0) != 0: + issues.append(_issue("worker_validation_failed", None, "Worker validation command exited non-zero.")) + + +def validate_evidence_files(bundle: PatchBundle, run_root: Path) -> list[ValidationIssue]: + issues: list[ValidationIssue] = [] + for raw in bundle.evidence_files: + try: + normalized = normalize_repo_relative_path(raw) + except PathValidationError as exc: + issues.append(_issue("unsafe_evidence_path", raw, exc.detail)) + continue + if is_local_secret_path(normalized): + issues.append(_issue("unsafe_evidence_path", normalized, "Evidence refs cannot point to local secret-looking paths.")) + continue + if not (run_root / normalized).exists(): + issues.append(_issue("missing_evidence_file", normalized, "Evidence file reference does not exist under the run root.")) + return issues + + +def _receipt_from_issues( + bundle: PatchBundle | None, + issues: list[ValidationIssue], + *, + bundle_id: str, + task_id: str, + worker_id: str, + run_id: str, + base_commit: str, + touched_paths: list[str] | None = None, + diff_paths: list[str] | None = None, + evidence_files: list[str] | None = None, + patch_sha256: str | None = None, + timestamp: str | None = None, +) -> BundleReceipt: + reason_codes = [] + seen: set[str] = set() + for issue in issues: + if issue.code not in seen: + seen.add(issue.code) + reason_codes.append(issue.code) + accepted = not reason_codes + return BundleReceipt( + schema=SCHEMA_RECEIPT, + receipt_id=f"receipt-{safe_id(bundle_id)}", + bundle_id=bundle_id, + task_id=task_id, + worker_id=worker_id, + run_id=run_id, + base_commit=base_commit, + validation_status="accepted" if accepted else "rejected", + integratable=accepted and (bundle.result_status == "patch_ready" if bundle else False), + reason_codes=reason_codes, + issues=issues, + normalized_touched_paths=touched_paths or [], + diff_paths=diff_paths or [], + changed_file_summary=bundle.changed_file_summary if bundle else [], + worker_validation_commands=bundle.validation_commands if bundle else [], + evidence_files=evidence_files or [], + risk_flags=bundle.risk_flags if bundle else [], + patch_sha256=patch_sha256, + validator_version=VALIDATOR_VERSION, + validated_at=timestamp or DEFAULT_TIMESTAMP, + ) + + +def validate_bundle( + bundle: PatchBundle, + lease: LeaseSpec, + *, + expected_run_id: str | None, + expected_base_commit: str | None, + bundle_dir: Path, + run_root: Path, + timestamp: str | None = None, +) -> BundleReceipt: + issues: list[ValidationIssue] = [] + touched_paths, touched_issues = normalize_optional_path_list(bundle.touched_paths) + issues.extend(touched_issues) + evidence_files, evidence_path_issues = normalize_optional_path_list(bundle.evidence_files) + issues.extend([_issue("unsafe_evidence_path", issue.path, issue.detail) for issue in evidence_path_issues]) + if expected_run_id and bundle.run_id != expected_run_id: + issues.append(_issue("run_id_mismatch", None, f"bundle run_id {bundle.run_id} does not match {expected_run_id}")) + if expected_base_commit and bundle.base_commit != expected_base_commit: + issues.append( + _issue("base_commit_mismatch", None, f"bundle base_commit {bundle.base_commit} does not match expected base") + ) + _append_path_safety_issues(touched_paths, lease, issues) + issues.extend(validate_evidence_files(bundle, run_root)) + _append_worker_validation_issues(bundle, issues) + + patch_text, patch_sha, load_issues = load_patch_text(bundle, bundle_dir, run_root) + issues.extend(load_issues) + diff_paths: list[str] = [] + if patch_text is not None: + summary = parse_git_diff(patch_text) + issues.extend(summary.path_errors) + diff_paths = summary.paths + _append_path_safety_issues(diff_paths, lease, issues) + declared = set(touched_paths) + actual = set(diff_paths) + for path in sorted(actual - declared): + issues.append(_issue("diff_path_not_declared", path, "Patch changes a path not declared in touched_paths.")) + for path in sorted(declared - actual): + issues.append(_issue("declared_path_not_in_diff", path, "Declared touched_path is not present in the diff.")) + if summary.binary and not lease.allow_binary: + issues.append(_issue("binary_patch_prohibited", None, "Binary patch markers are prohibited by the task lease.")) + for path in summary.symlink_paths: + if not lease.allow_symlinks: + issues.append(_issue("symlink_patch_prohibited", path, "Symlink patch modes are prohibited by the task lease.")) + for path in summary.submodule_paths: + if not lease.allow_submodules: + issues.append(_issue("submodule_patch_prohibited", path, "Submodule patch modes are prohibited by the task lease.")) + for path in summary.delete_paths: + if path not in lease.allowed_deletes: + issues.append(_issue("undeclared_delete", path, "Deletes must be explicitly allowed by the task lease.")) + for rename in summary.renames: + source = rename.get("from", "") + destination = rename.get("to", "") + allowed_pair = any(item.get("from") == source and item.get("to") == destination for item in lease.allowed_renames) + if not allowed_pair or not path_allowed(source, lease.allowed_paths) or not path_allowed(destination, lease.allowed_paths): + issues.append(_issue("unowned_rename", f"{source}->{destination}", "Rename source and destination must be lease-owned and declared.")) + for path, delta in summary.lockfile_line_deltas.items(): + if build_safety.path_is_lockfile(path) and (path not in lease.allowed_lockfiles or delta > lease.max_lockfile_changed_lines): + issues.append(_issue("broad_lockfile_change", path, "Lockfile changes must be explicitly leased and below the line budget.")) + for path in summary.secret_like_added_paths: + issues.append(_issue("secret_like_content", path, "Patch adds secret-looking content; value redacted.")) + + return _receipt_from_issues( + bundle, + issues, + bundle_id=bundle.bundle_id, + task_id=bundle.task_id, + worker_id=bundle.worker_id, + run_id=bundle.run_id, + base_commit=bundle.base_commit, + touched_paths=touched_paths, + diff_paths=diff_paths, + evidence_files=evidence_files, + patch_sha256=patch_sha, + timestamp=timestamp, + ) + + +def receipt_to_dict(receipt: BundleReceipt) -> dict[str, Any]: + payload = asdict(receipt) + payload["issues"] = [asdict(issue) for issue in receipt.issues] + return payload + + +def validate_bundle_manifest( + bundle_path: Path, + lease_manifest: Path, + out_dir: Path, + *, + expected_run_id: str | None = None, + expected_base_commit: str | None = None, + timestamp: str | None = None, +) -> BundleReceipt: + lease_run_id, lease_base_commit, leases = load_lease_manifest(lease_manifest) + payload = read_json(bundle_path) + bundle, parse_issues = bundle_from_payload(payload) + run_id = str(payload.get("run_id") or expected_run_id or lease_run_id or "") + task_id = str(payload.get("task_id") or "") + worker_id = str(payload.get("worker_id") or "") + bundle_id = str(payload.get("bundle_id") or bundle_path.stem) + base_commit = str(payload.get("base_commit") or expected_base_commit or lease_base_commit or "") + if bundle is None: + receipt = _receipt_from_issues( + None, + parse_issues, + bundle_id=bundle_id, + task_id=task_id, + worker_id=worker_id, + run_id=run_id, + base_commit=base_commit, + timestamp=timestamp, + ) + else: + lease = leases.get(bundle.task_id) + if lease is None: + receipt = _receipt_from_issues( + bundle, + [_issue("missing_task_lease", bundle.task_id, "No authoritative task lease was found.")], + bundle_id=bundle.bundle_id, + task_id=bundle.task_id, + worker_id=bundle.worker_id, + run_id=bundle.run_id, + base_commit=bundle.base_commit, + touched_paths=[], + diff_paths=[], + evidence_files=[], + timestamp=timestamp, + ) + else: + receipt = validate_bundle( + bundle, + lease, + expected_run_id=expected_run_id or lease_run_id, + expected_base_commit=expected_base_commit or lease_base_commit, + bundle_dir=bundle_path.parent, + run_root=out_dir, + timestamp=timestamp, + ) + receipts_dir = out_dir / "receipts" + write_json(receipts_dir / f"{receipt.receipt_id}.json", receipt_to_dict(receipt)) + return receipt + + +def write_markdown_report(out_dir: Path, report: dict[str, Any], receipts: list[BundleReceipt]) -> Path: + lines = [ + "# Patch Bundle Collection Report", + "", + f"- Run ID: `{report.get('run_id')}`", + f"- Base commit: `{report.get('base_commit')}`", + f"- Accepted: {report.get('accepted_count')}", + f"- Rejected: {report.get('rejected_count')}", + f"- Evidence-only accepted: {report.get('evidence_only_count')}", + "", + "## Rejection Reasons", + "", + ] + reasons = report.get("rejection_reason_counts") or {} + if reasons: + for code, count in sorted(reasons.items()): + lines.append(f"- `{code}`: {count}") + else: + lines.append("- None") + lines.extend(["", "## Receipts", ""]) + for receipt in sorted(receipts, key=lambda item: item.bundle_id): + codes = ", ".join(receipt.reason_codes) if receipt.reason_codes else "accepted" + lines.append(f"- `{receipt.bundle_id}` `{receipt.validation_status}`: {codes}") + path = out_dir / "patch-bundle-report.md" + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + return path + + +def collect_patch_bundles( + bundles_dir: Path, + lease_manifest: Path, + out_dir: Path, + *, + run_id: str, + base_commit: str | None = None, + timestamp: str | None = None, +) -> dict[str, Any]: + out_dir.mkdir(parents=True, exist_ok=True) + receipts: list[BundleReceipt] = [] + for bundle_path in sorted(bundles_dir.glob("*.json")): + receipt = validate_bundle_manifest( + bundle_path, + lease_manifest, + out_dir, + expected_run_id=run_id, + expected_base_commit=base_commit, + timestamp=timestamp, + ) + receipts.append(receipt) + reason_counts: Counter[str] = Counter() + for receipt in receipts: + for code in receipt.reason_codes: + reason_counts[code] += 1 + accepted = [receipt for receipt in receipts if receipt.validation_status == "accepted"] + rejected = [receipt for receipt in receipts if receipt.validation_status == "rejected"] + evidence_only = [ + receipt + for receipt in accepted + if receipt.integratable is False and not receipt.diff_paths and receipt.patch_sha256 is None + ] + report = { + "schema": SCHEMA_REPORT, + "run_id": run_id, + "base_commit": base_commit or "", + "accepted_count": len(accepted), + "rejected_count": len(rejected), + "evidence_only_count": len(evidence_only), + "receipt_count": len(receipts), + "rejection_reason_counts": dict(sorted(reason_counts.items())), + "receipts": [f"receipts/{receipt.receipt_id}.json" for receipt in sorted(receipts, key=lambda item: item.bundle_id)], + "validator_version": VALIDATOR_VERSION, + } + write_json(out_dir / "patch-bundle-report.json", report) + write_markdown_report(out_dir, report, receipts) + (out_dir / "validation-summary.txt").write_text( + f"accepted={len(accepted)} rejected={len(rejected)} evidence_only={len(evidence_only)}\n", + encoding="utf-8", + ) + return report + + +def _unified_diff(path: str, before: str, after: str) -> str: + before_lines = before.splitlines(True) + after_lines = after.splitlines(True) + header = f"diff --git a/{path} b/{path}\n" + body = "".join( + __import__("difflib").unified_diff( + before_lines, + after_lines, + fromfile=f"a/{path}", + tofile=f"b/{path}", + lineterm="", + ) + ) + return header + body.replace("\n--- ", "--- ", 1) if body.startswith("--- ") else header + body + + +def fixture_patch(path: str, added: str = "print('fixture')\n") -> str: + return ( + f"diff --git a/{path} b/{path}\n" + f"--- a/{path}\n" + f"+++ b/{path}\n" + "@@ -1 +1,2 @@\n" + "-old line\n" + f"+old line\n+{added}" + ) + + +def write_bundle(path: Path, payload: dict[str, Any]) -> None: + write_json(path, payload) + + +def base_bundle( + *, + bundle_id: str, + task_id: str, + worker_id: str, + run_id: str, + base_commit: str, + touched_paths: list[str], + diff_path: str | None, + result_status: str = "patch_ready", + evidence_files: list[str] | None = None, + validation_commands: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "schema": SCHEMA_PATCH_BUNDLE, + "bundle_id": bundle_id, + "task_id": task_id, + "worker_id": worker_id, + "run_id": run_id, + "base_commit": base_commit, + "touched_paths": touched_paths, + "diff_path": diff_path, + "patch_content_ref": None, + "changed_file_summary": [ + {"path": path, "change_type": "modify", "summary": "Fixture patch bundle change."} + for path in touched_paths + if not path.startswith("/") and ".." not in path.split("/") + ], + "validation_commands": validation_commands if validation_commands is not None else [{"cmd": "python3 -m pytest -q tests/fixture.py", "exit_code": 0}], + "evidence_files": evidence_files or ["input/evidence/worker-a-validation.txt"], + "result_status": result_status, + "risk_flags": [], + } + + +def build_fixture_inputs(out_dir: Path, *, base_commit: str, run_id: str = "patch-bundle-fixture") -> dict[str, Any]: + input_dir = out_dir / "input" + bundles_dir = input_dir / "bundles" + patches_dir = input_dir / "patches" + evidence_dir = input_dir / "evidence" + for path in (bundles_dir, patches_dir, evidence_dir): + path.mkdir(parents=True, exist_ok=True) + (evidence_dir / "worker-a-validation.txt").write_text("fixture validation passed\n", encoding="utf-8") + (evidence_dir / "research-note.md").write_text("Evidence-only fixture result.\n", encoding="utf-8") + leases = { + "schema": SCHEMA_LEASES, + "run_id": run_id, + "base_commit": base_commit, + "tasks": { + "task-owned-src": { + "allowed_paths": ["src/owned/**", "tests/owned/**"], + "protected_paths": DEFAULT_PROTECTED_PATHS, + "allowed_deletes": [], + "allowed_renames": [], + "allowed_lockfiles": [], + "allow_binary": False, + "allow_symlinks": False, + "allow_submodules": False, + "max_lockfile_changed_lines": 100, + }, + "task-research-only": { + "allowed_paths": [], + "protected_paths": DEFAULT_PROTECTED_PATHS, + "allowed_deletes": [], + "allowed_renames": [], + "allowed_lockfiles": [], + "allow_binary": False, + "allow_symlinks": False, + "allow_submodules": False, + "max_lockfile_changed_lines": 100, + }, + "task-lockfile": { + "allowed_paths": ["package-lock.json"], + "protected_paths": DEFAULT_PROTECTED_PATHS, + "allowed_deletes": [], + "allowed_renames": [], + "allowed_lockfiles": ["package-lock.json"], + "allow_binary": False, + "allow_symlinks": False, + "allow_submodules": False, + "max_lockfile_changed_lines": 2, + }, + }, + } + write_json(input_dir / "leases.json", leases) + patches: dict[str, str] = { + "bundle-safe-001.diff": fixture_patch("src/owned/example.py", "def fixture_helper():\n return 'ok'\n"), + "bundle-outside-lease.diff": fixture_patch("src/unowned/outside.py"), + "bundle-protected-path.diff": fixture_patch(".env"), + "bundle-env-mcp.diff": fixture_patch(".env.mcp"), + "bundle-traversal.diff": "diff --git a/../outside.txt b/../outside.txt\n--- a/../outside.txt\n+++ b/../outside.txt\n@@ -1 +1 @@\n-old\n+new\n", + "bundle-symlink.diff": "diff --git a/src/owned/link b/src/owned/link\nnew file mode 120000\n--- /dev/null\n+++ b/src/owned/link\n@@ -0,0 +1 @@\n+target\n", + "bundle-submodule.diff": "diff --git a/src/owned/submodule b/src/owned/submodule\nnew file mode 160000\n--- /dev/null\n+++ b/src/owned/submodule\n@@ -0,0 +1 @@\n+Subproject commit 0123456789abcdef0123456789abcdef01234567\n", + "bundle-binary.diff": "diff --git a/src/owned/image.png b/src/owned/image.png\nBinary files /dev/null and b/src/owned/image.png differ\nGIT binary patch\nliteral 0\n", + "bundle-undeclared-delete.diff": "diff --git a/src/owned/delete_me.py b/src/owned/delete_me.py\ndeleted file mode 100644\n--- a/src/owned/delete_me.py\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n", + "bundle-unowned-rename.diff": "diff --git a/src/unowned/old.py b/src/owned/new.py\nsimilarity index 100%\nrename from src/unowned/old.py\nrename to src/owned/new.py\n", + "bundle-broad-lockfile.diff": "diff --git a/package-lock.json b/package-lock.json\n--- a/package-lock.json\n+++ b/package-lock.json\n@@ -1 +1,5 @@\n-{}\n+{\n+ \"a\": 1,\n+ \"b\": 2,\n+ \"c\": 3\n+}\n", + "bundle-secret-content.diff": fixture_patch("src/owned/secret_value.py", "FAKE_OPENAI_API_KEY='sk-fake-call9-not-a-real-secret'\n"), + } + for name, text in patches.items(): + (patches_dir / name).write_text(text, encoding="utf-8") + bundle_specs = [ + ("bundle-safe-001", "task-owned-src", ["src/owned/example.py"], "input/patches/bundle-safe-001.diff"), + ("bundle-outside-lease", "task-owned-src", ["src/unowned/outside.py"], "input/patches/bundle-outside-lease.diff"), + ("bundle-protected-path", "task-owned-src", [".env"], "input/patches/bundle-protected-path.diff"), + ("bundle-env-mcp", "task-owned-src", [".env.mcp"], "input/patches/bundle-env-mcp.diff"), + ("bundle-traversal", "task-owned-src", ["../outside.txt"], "input/patches/bundle-traversal.diff"), + ("bundle-absolute-path", "task-owned-src", ["/tmp/outside.txt"], "input/patches/bundle-safe-001.diff"), + ("bundle-symlink", "task-owned-src", ["src/owned/link"], "input/patches/bundle-symlink.diff"), + ("bundle-submodule", "task-owned-src", ["src/owned/submodule"], "input/patches/bundle-submodule.diff"), + ("bundle-binary", "task-owned-src", ["src/owned/image.png"], "input/patches/bundle-binary.diff"), + ("bundle-undeclared-delete", "task-owned-src", ["src/owned/delete_me.py"], "input/patches/bundle-undeclared-delete.diff"), + ("bundle-unowned-rename", "task-owned-src", ["src/unowned/old.py", "src/owned/new.py"], "input/patches/bundle-unowned-rename.diff"), + ("bundle-broad-lockfile", "task-lockfile", ["package-lock.json"], "input/patches/bundle-broad-lockfile.diff"), + ("bundle-secret-content", "task-owned-src", ["src/owned/secret_value.py"], "input/patches/bundle-secret-content.diff"), + ] + for bundle_id, task_id, touched, diff_path in bundle_specs: + write_bundle( + bundles_dir / f"{bundle_id}.json", + base_bundle( + bundle_id=bundle_id, + task_id=task_id, + worker_id=f"worker-{bundle_id}", + run_id=run_id, + base_commit=base_commit, + touched_paths=touched, + diff_path=diff_path, + ), + ) + write_bundle( + bundles_dir / "bundle-evidence-001.json", + base_bundle( + bundle_id="bundle-evidence-001", + task_id="task-research-only", + worker_id="worker-evidence", + run_id=run_id, + base_commit=base_commit, + touched_paths=[], + diff_path=None, + result_status="evidence_only", + evidence_files=["input/evidence/research-note.md"], + validation_commands=[], + ), + ) + return {"run_id": run_id, "input_dir": input_dir.as_posix(), "bundle_count": 14} + + +def run_validate_from_args(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + out_dir = Path(args.out) + receipt = validate_bundle_manifest( + Path(args.bundle), + Path(args.lease_manifest), + out_dir, + expected_run_id=getattr(args, "run_id", "") or None, + expected_base_commit=getattr(args, "base_commit", "") or None, + ) + payload = receipt_to_dict(receipt) + return payload, 0 if receipt.validation_status == "accepted" else 1 + + +def run_collect_from_args(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + report = collect_patch_bundles( + Path(args.bundles_dir), + Path(args.lease_manifest), + Path(args.out), + run_id=args.run_id, + base_commit=getattr(args, "base_commit", "") or None, + ) + return report, 0 + + +def add_patch_bundle_args(parser: argparse.ArgumentParser) -> None: + sub = parser.add_subparsers(dest="patch_bundle_command", required=True) + validate = sub.add_parser("validate", help="Validate one local Patch Swarm bundle without applying it.") + validate.add_argument("--bundle", required=True) + validate.add_argument("--lease-manifest", required=True) + validate.add_argument("--out", required=True) + validate.add_argument("--run-id", default="") + validate.add_argument("--base-commit", default="") + validate.add_argument("--json", action="store_true") + collect = sub.add_parser("collect", help="Collect and validate all local Patch Swarm bundles from a directory.") + collect.add_argument("--bundles-dir", required=True) + collect.add_argument("--lease-manifest", required=True) + collect.add_argument("--out", required=True) + collect.add_argument("--run-id", required=True) + collect.add_argument("--base-commit", default="") + collect.add_argument("--json", action="store_true") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Collect and validate local Patch Swarm patch bundles.") + add_patch_bundle_args(parser) + args = parser.parse_args(argv) + if args.patch_bundle_command == "validate": + payload, code = run_validate_from_args(args) + else: + payload, code = run_collect_from_args(args) + print(stable_json_dumps(payload), end="") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_patch_swarm_console.py b/scripts/parallel_delivery_patch_swarm_console.py new file mode 100644 index 0000000..a7f4ca2 --- /dev/null +++ b/scripts/parallel_delivery_patch_swarm_console.py @@ -0,0 +1,918 @@ +#!/usr/bin/env python3 +"""Patch Swarm console aggregation and static HTML rendering. + +The console is intentionally artifact-backed: it reads an existing Patch Swarm +run directory, normalizes the operator status view, and writes only console +export files into the requested output directory. +""" + +from __future__ import annotations + +import argparse +import html +import json +from dataclasses import asdict, dataclass, replace +from datetime import datetime, timezone +from html.parser import HTMLParser +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlparse + + +ROOT = Path(__file__).resolve().parents[1] +RUNS_ROOT = ROOT / "workspace" / "runs" / "parallel-delivery" +SCHEMA_VERSION = "cento.parallel_delivery.patch_swarm_console.v1" +VALIDATION_SCHEMA_VERSION = "cento.parallel_delivery.patch_swarm_console.validation.v1" + + +@dataclass(frozen=True) +class EvidenceLink: + label: str + path: str + exists: bool + kind: str + + +@dataclass(frozen=True) +class BundleBucketSummary: + pending: int + accepted: int + rejected: int + safe_apply: int + needs_rebase: int + needs_human_review: int + reject: int + + +@dataclass(frozen=True) +class WorkerSummary: + simulated: bool + total_workers: int + active_workers: int + wave_count: int + max_parallel_agents: int + max_observed_parallel_workers: int + bounded_parallelism_passed: bool + + +@dataclass(frozen=True) +class TaskGraphSummary: + total_tasks: int + dependency_edges: int + root_tasks: int + blocked_tasks: int + conflict_tasks: int + + +@dataclass(frozen=True) +class IntegrationStatus: + result: str + groups: int + conflicts: int + safe_apply: int + needs_rebase: int + needs_human_review: int + reject: int + conflict_report_path: str | None + + +@dataclass(frozen=True) +class ValidationStatus: + result: str + passed_gates: int + failed_gates: int + failing_gates: tuple[str, ...] + report_path: str | None + + +@dataclass(frozen=True) +class ReleaseCandidateStatus: + created: bool + status: str + path: str | None + demo_evidence_path: str | None + + +@dataclass(frozen=True) +class PatchSwarmConsoleData: + schema_version: str + run_id: str + run_dir: str + generated_at: str + current_run: dict[str, Any] + candidate_count: int + task_graph: TaskGraphSummary + workers: WorkerSummary + bundles: BundleBucketSummary + integration: IntegrationStatus + validation: ValidationStatus + release_candidate: ReleaseCandidateStatus + evidence_links: tuple[EvidenceLink, ...] + next_action: str + warnings: tuple[str, ...] + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json(payload: Any, *, pretty: bool = True) -> str: + if pretty: + return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=True) + "\n" + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n" + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def normalize_run_dir(path: Path) -> Path: + expanded = path.expanduser() + if not expanded.is_absolute(): + expanded = ROOT / expanded + return expanded.resolve() + + +def load_json_file(path: Path) -> tuple[dict[str, Any] | list[Any] | None, str | None]: + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None, None + except OSError as exc: + return None, f"{rel(path)}: {exc}" + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + return None, f"{rel(path)}: invalid JSON: {exc}" + if not isinstance(payload, (dict, list)): + return None, f"{rel(path)}: expected JSON object or array" + return payload, None + + +def _as_dict(payload: Any) -> dict[str, Any] | None: + return payload if isinstance(payload, dict) else None + + +def _as_list(payload: Any) -> list[Any]: + return payload if isinstance(payload, list) else [] + + +def _first_json( + run_dir: Path, + candidates: list[str], + warnings: list[str], +) -> tuple[dict[str, Any] | list[Any] | None, str | None]: + for candidate in candidates: + path = run_dir / candidate + payload, error = load_json_file(path) + if error: + warnings.append(error) + if payload is not None: + return payload, candidate + return None, None + + +def _first_existing(run_dir: Path, candidates: list[str]) -> str | None: + for candidate in candidates: + if (run_dir / candidate).exists(): + return candidate + return None + + +def _int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _result_from(*values: Any, default: str = "unknown") -> str: + for value in values: + clean = str(value or "").strip() + if clean: + if clean in {"ok", "true"}: + return "passed" + if clean in {"completed", "dry_run_completed", "rc_fixture_validated"}: + return "passed" + return clean + return default + + +def _count_bucket(value: Any) -> int: + if isinstance(value, list): + return len(value) + if isinstance(value, dict): + for key in ("count", "total", "size"): + if key in value: + return _int(value.get(key)) + return len(value) + return _int(value) + + +def compute_task_graph_summary(tasks_data: Any, integration_plan: dict[str, Any] | None = None) -> TaskGraphSummary: + data = _as_dict(tasks_data) or {} + tasks = _as_list(data.get("tasks")) + nodes = _as_list(data.get("nodes")) + edges = _as_list(data.get("edges")) + if tasks: + total_tasks = len(tasks) + edge_count = sum(len(_as_list(task.get("dependencies"))) + len(_as_list(task.get("depends_on"))) for task in tasks if isinstance(task, dict)) + blocked = sum(1 for task in tasks if isinstance(task, dict) and (task.get("blocked") or str(task.get("state") or "").lower() == "blocked")) + roots = sum( + 1 + for task in tasks + if isinstance(task, dict) + and not _as_list(task.get("dependencies")) + and not _as_list(task.get("depends_on")) + ) + conflict_tasks = { + str(task.get("task_id")) + for task in tasks + if isinstance(task, dict) + and ( + task.get("requires_manual_review") + or task.get("human_handoff") + or str(task.get("state") or "").lower() in {"conflict", "blocked"} + ) + } + else: + total_tasks = len(nodes) + edge_count = len(edges) + incoming = {str(edge.get("to")) for edge in edges if isinstance(edge, dict) and edge.get("to")} + roots = sum(1 for node in nodes if isinstance(node, dict) and str(node.get("task_id")) not in incoming) + blocked = sum(1 for node in nodes if isinstance(node, dict) and str(node.get("state") or "").lower() == "blocked") + conflict_tasks = set() + for edge in edges: + if not isinstance(edge, dict): + continue + hay = " ".join(str(edge.get(key) or "").lower() for key in ("type", "reason", "status", "state")) + if any(word in hay for word in ("conflict", "share", "manual", "human")): + for key in ("from", "to", "source", "target"): + if edge.get(key): + conflict_tasks.add(str(edge[key])) + if integration_plan: + for bucket_key in ("needs_human_review", "conflicts", "conflict_tasks"): + for item in _as_list(integration_plan.get(bucket_key)): + if isinstance(item, dict) and item.get("task_id"): + conflict_tasks.add(str(item["task_id"])) + elif item: + conflict_tasks.add(str(item)) + return TaskGraphSummary( + total_tasks=total_tasks, + dependency_edges=edge_count, + root_tasks=roots, + blocked_tasks=blocked, + conflict_tasks=len({item for item in conflict_tasks if item and item != "None"}), + ) + + +def compute_bundle_summary( + validation_summary: dict[str, Any] | None, + patch_validation: dict[str, Any] | None, + integration_plan: dict[str, Any] | None, +) -> BundleBucketSummary: + validation_summary = validation_summary or {} + patch_validation = patch_validation or {} + integration_plan = integration_plan or {} + counts = validation_summary.get("counts") if isinstance(validation_summary.get("counts"), dict) else {} + buckets = integration_plan.get("buckets") if isinstance(integration_plan.get("buckets"), dict) else {} + accepted = _count_bucket(patch_validation.get("accepted")) + rejected = _count_bucket(patch_validation.get("rejected")) + if not accepted: + accepted = _int(counts.get("accepted_patch_bundles") or validation_summary.get("accepted_patch_bundles")) + if not rejected: + rejected = _int(counts.get("rejected_patch_bundles") or validation_summary.get("rejected_patch_bundles")) + safe_apply = _count_bucket(buckets.get("safe_apply")) or _count_bucket(integration_plan.get("safe_apply")) or len(_as_list(integration_plan.get("queue"))) + needs_rebase = _count_bucket(buckets.get("needs_rebase")) or _count_bucket(integration_plan.get("needs_rebase")) + needs_human_review = _count_bucket(buckets.get("needs_human_review")) or _count_bucket(integration_plan.get("needs_human_review")) + reject = _count_bucket(buckets.get("reject")) or _count_bucket(integration_plan.get("reject")) or len(_as_list(integration_plan.get("rejected"))) + candidate_count = _int(validation_summary.get("candidate_count") or validation_summary.get("candidate_target")) + pending = max(0, candidate_count - accepted - rejected) + return BundleBucketSummary( + pending=pending, + accepted=accepted, + rejected=rejected, + safe_apply=safe_apply, + needs_rebase=needs_rebase, + needs_human_review=needs_human_review, + reject=reject, + ) + + +def compute_worker_summary( + validation_summary: dict[str, Any] | None, + worker_waves: dict[str, Any] | list[Any] | None, +) -> WorkerSummary: + validation_summary = validation_summary or {} + batches = _as_list(validation_summary.get("simulated_worker_batches")) + waves = _as_list(worker_waves) or _as_list((_as_dict(worker_waves) or {}).get("waves")) + max_parallel_agents = _int(validation_summary.get("max_parallel_agents") or (_as_dict(worker_waves) or {}).get("max_parallel_agents")) + observed = 0 + for batch in [*batches, *waves]: + if not isinstance(batch, dict): + continue + observed = max(observed, len(_as_list(batch.get("task_ids") or batch.get("workers")))) + active_workers = sum( + 1 + for wave in waves + if isinstance(wave, dict) + for worker in _as_list(wave.get("workers")) + if isinstance(worker, dict) and str(worker.get("status") or "").lower() in {"active", "running", "working"} + ) + total_workers = max(max_parallel_agents, observed, _int((_as_dict(worker_waves) or {}).get("total_workers"))) + return WorkerSummary( + simulated=bool(validation_summary.get("fixture") or batches), + total_workers=total_workers, + active_workers=active_workers, + wave_count=len(waves) or len(batches), + max_parallel_agents=max_parallel_agents, + max_observed_parallel_workers=observed, + bounded_parallelism_passed=not max_parallel_agents or observed <= max_parallel_agents, + ) + + +def compute_validation_status( + validation_summary: dict[str, Any] | None, + validation_report_path: str | None, +) -> ValidationStatus: + if not validation_summary: + return ValidationStatus("missing", 0, 1, ("validation-summary.json missing",), validation_report_path) + gates: list[dict[str, Any]] = [] + for key, value in validation_summary.items(): + if key.endswith("_checks") and isinstance(value, list): + gates.extend(item for item in value if isinstance(item, dict)) + failed = [ + str(item.get("name") or item.get("gate") or item.get("artifact") or "unnamed gate") + for item in gates + if item.get("ok") is False or item.get("status") == "failed" + ] + failed_count = len(failed) or _int((validation_summary.get("counts") or {}).get("failed_checks") if isinstance(validation_summary.get("counts"), dict) else 0) + passed_count = max(0, len(gates) - failed_count) + result = _result_from(validation_summary.get("overall"), validation_summary.get("result"), validation_summary.get("status"), default="unknown") + return ValidationStatus(result, passed_count, failed_count, tuple(failed), validation_report_path) + + +def compute_integration_status( + integration_plan: dict[str, Any] | None, + integration_receipt: dict[str, Any] | None, + dry_run_summary: dict[str, Any] | None, + path_leases: dict[str, Any] | None, + bundles: BundleBucketSummary, + conflict_report_path: str | None, +) -> IntegrationStatus: + integration_plan = integration_plan or {} + integration_receipt = integration_receipt or {} + dry_run_summary = dry_run_summary or {} + path_leases = path_leases or {} + groups = len(_as_list(integration_plan.get("groups"))) or len(_as_list(path_leases.get("parallel_groups"))) + conflicts = ( + len(_as_list(integration_plan.get("conflicts"))) + or len(_as_list(path_leases.get("conflicts"))) + or bundles.needs_human_review + ) + result = _result_from( + dry_run_summary.get("result"), + dry_run_summary.get("status"), + integration_receipt.get("final_state"), + integration_receipt.get("status"), + "passed" if integration_plan else "", + default="missing", + ) + return IntegrationStatus( + result=result, + groups=groups, + conflicts=conflicts, + safe_apply=bundles.safe_apply, + needs_rebase=bundles.needs_rebase, + needs_human_review=bundles.needs_human_review, + reject=bundles.reject, + conflict_report_path=conflict_report_path, + ) + + +def compute_release_candidate_status( + release_candidate: dict[str, Any] | None, + release_candidate_path: str | None, + demo_evidence_path: str | None, +) -> ReleaseCandidateStatus: + if not release_candidate: + return ReleaseCandidateStatus(False, "missing", None, demo_evidence_path) + status = _result_from( + release_candidate.get("status"), + release_candidate.get("state"), + release_candidate.get("overall"), + default="created", + ) + if status == "passed": + status = "ready_for_operator_review" + return ReleaseCandidateStatus(True, status, release_candidate_path, demo_evidence_path) + + +def collect_evidence_links(run_dir: Path, console_data: PatchSwarmConsoleData | None = None) -> list[EvidenceLink]: + del console_data + specs = [ + ("Validation Summary", ["validation-summary.json", "validation_summary.json"], "json"), + ("Validation Report", ["validation-report.md", "validation_report.md"], "markdown"), + ("Request", ["00-request/request.json", "request.md", "run.json"], "markdown"), + ("Task Graph", ["01-split/tasks.json", "task-graph.json", "split-plan.json"], "json"), + ("Path Leases", ["02-leases/path-leases.json", "path-leases.json"], "json"), + ("Worker Packets", ["03-worker-packets/worker-waves.json", "worker-packets/codex-packet-index.json"], "json"), + ("Patch Validation", ["05-patch-validation/patch-validation-summary.json", "validation/patch-bundle-validation.json"], "json"), + ("Integration Plan", ["06-integration-plan/integration-plan.json", "integration/integration-plan.json", "integration_execution/integration_execution.json"], "json"), + ("Conflict Report", ["06-integration-plan/conflict-report.md", "integration/conflict-report.md", "integration/rejected-patches.json"], "markdown"), + ("Dry Run Integration", ["07-dry-run-integration/dry-run-summary.json", "integration/integration-receipt.json"], "json"), + ("Release Candidate", ["08-release-candidate/release-candidate.json", "release-candidate/release-candidate.json"], "json"), + ("Demo Evidence", ["08-release-candidate/demo-evidence.md", "release-candidate/demo-evidence.md", "release-candidate/release-notes.md"], "markdown"), + ("Console Data", ["console-data.json"], "json"), + ] + links: list[EvidenceLink] = [] + for label, candidates, kind in specs: + chosen = _first_existing(run_dir, candidates) or candidates[0] + exists = (run_dir / chosen).exists() or chosen == "console-data.json" + links.append(EvidenceLink(label=label, path=chosen, exists=exists, kind=kind)) + return links + + +def compute_next_action(console_data: PatchSwarmConsoleData) -> str: + if console_data.validation.result == "missing": + return "Generate or repair fixture validation summary" + if console_data.validation.result in {"failed", "error", "blocked"} or console_data.validation.failed_gates > 0: + return "Inspect validation-report.md and failing stage" + if console_data.bundles.rejected > 0 or console_data.bundles.reject > 0: + return "Review rejected bundles before release candidate" + if console_data.integration.needs_human_review > 0 or console_data.integration.conflicts > 0: + return "Resolve conflicts in conflict-report.md" + if console_data.integration.result in {"failed", "error", "blocked"}: + return "Run rebase or dry-run repair for affected bundles" + if not console_data.release_candidate.created: + return "Create release candidate evidence" + if console_data.validation.result == "passed" and console_data.release_candidate.created: + return "Ready for operator demo/release review" + return "Inspect run artifacts and repair missing status evidence" + + +def collect_patch_swarm_console_data(run_dir: Path) -> PatchSwarmConsoleData: + resolved = normalize_run_dir(run_dir) + warnings: list[str] = [] + validation_payload, validation_path = _first_json(resolved, ["validation-summary.json", "validation_summary.json"], warnings) + split_payload, _split_path = _first_json(resolved, ["01-split/tasks.json", "split-plan.json"], warnings) + task_graph_payload, _task_graph_path = _first_json(resolved, ["task-graph.json", "01-split/task-graph.json"], warnings) + path_leases_payload, _path_leases_path = _first_json(resolved, ["02-leases/path-leases.json", "path-leases.json"], warnings) + worker_waves_payload, _worker_waves_path = _first_json(resolved, ["03-worker-packets/worker-waves.json"], warnings) + patch_validation_payload, _patch_validation_path = _first_json( + resolved, + ["05-patch-validation/patch-validation-summary.json", "validation/patch-bundle-validation.json"], + warnings, + ) + integration_payload, _integration_path = _first_json( + resolved, + ["06-integration-plan/integration-plan.json", "integration/integration-plan.json", "integration_execution/integration_execution.json"], + warnings, + ) + integration_receipt_payload, _integration_receipt_path = _first_json(resolved, ["integration/integration-receipt.json"], warnings) + dry_run_payload, _dry_run_path = _first_json(resolved, ["07-dry-run-integration/dry-run-summary.json"], warnings) + release_payload, release_path = _first_json( + resolved, + ["08-release-candidate/release-candidate.json", "release-candidate/release-candidate.json", "release_candidate/release-candidate.json"], + warnings, + ) + manifest_payload, _manifest_path = _first_json(resolved, ["patch_swarm_manifest.json", "run.json"], warnings) + receipt_payload, _receipt_path = _first_json(resolved, ["patch_swarm_receipt.json"], warnings) + + validation_summary = _as_dict(validation_payload) + split_data = _as_dict(split_payload) + task_graph_data = _as_dict(task_graph_payload) + tasks_data = split_data if split_data and split_data.get("tasks") else task_graph_data or split_data or {} + path_leases = _as_dict(path_leases_payload) + patch_validation = _as_dict(patch_validation_payload) + integration_plan = _as_dict(integration_payload) + integration_receipt = _as_dict(integration_receipt_payload) + dry_run_summary = _as_dict(dry_run_payload) + release_candidate = _as_dict(release_payload) + manifest = _as_dict(manifest_payload) or {} + receipt = _as_dict(receipt_payload) or {} + + bundles = compute_bundle_summary(validation_summary, patch_validation, integration_plan) + validation_report_path = _first_existing(resolved, ["validation-report.md", "validation_report.md"]) + conflict_report_path = _first_existing( + resolved, + ["06-integration-plan/conflict-report.md", "integration/conflict-report.md", "integration/rejected-patches.json"], + ) + demo_evidence_path = _first_existing( + resolved, + ["08-release-candidate/demo-evidence.md", "release-candidate/demo-evidence.md", "release-candidate/release-notes.md"], + ) + candidate_count = ( + _int((validation_summary or {}).get("candidate_count")) + or _int((validation_summary or {}).get("candidate_target")) + or _int((split_data or {}).get("candidate_count")) + or _int(receipt.get("candidate_count")) + or bundles.accepted + bundles.pending + ) + current_run = { + "result": _result_from( + (validation_summary or {}).get("overall"), + (validation_summary or {}).get("result"), + (validation_summary or {}).get("status"), + receipt.get("status"), + manifest.get("status"), + default="unknown", + ), + "fixture": bool((validation_summary or {}).get("fixture") or manifest.get("fixture")), + "offline": True, + "dry_run": bool((manifest or {}).get("dry_run") or (integration_plan or {}).get("dry_run") or (integration_receipt or {}).get("dry_run")), + "created_at": str((validation_summary or {}).get("created_at") or manifest.get("created_at") or ""), + "updated_at": str((validation_summary or {}).get("updated_at") or manifest.get("updated_at") or ""), + } + data = PatchSwarmConsoleData( + schema_version=SCHEMA_VERSION, + run_id=str((validation_summary or {}).get("run_id") or manifest.get("run_id") or receipt.get("run_id") or resolved.name), + run_dir=rel(resolved), + generated_at=utc_now(), + current_run=current_run, + candidate_count=candidate_count, + task_graph=compute_task_graph_summary(tasks_data, integration_plan), + workers=compute_worker_summary(validation_summary, worker_waves_payload), + bundles=bundles, + integration=compute_integration_status( + integration_plan, + integration_receipt, + dry_run_summary, + path_leases, + bundles, + conflict_report_path, + ), + validation=compute_validation_status(validation_summary, validation_report_path), + release_candidate=compute_release_candidate_status(release_candidate, release_path, demo_evidence_path), + evidence_links=tuple(collect_evidence_links(resolved)), + next_action="", + warnings=tuple(warnings), + ) + return replace(data, next_action=compute_next_action(data)) + + +def console_data_to_dict(console_data: PatchSwarmConsoleData) -> dict[str, Any]: + return asdict(console_data) + + +def write_console_data(console_data: PatchSwarmConsoleData, output_dir: Path) -> Path: + resolved = normalize_run_dir(output_dir) + resolved.mkdir(parents=True, exist_ok=True) + path = resolved / "console-data.json" + path.write_text(stable_json(console_data_to_dict(console_data), pretty=True), encoding="utf-8") + return path + + +def _esc(value: Any) -> str: + return html.escape(str(value if value is not None else ""), quote=True) + + +def _status_class(value: str) -> str: + clean = str(value or "").lower() + if clean in {"passed", "ready_for_operator_review", "rc_fixture_validated", "completed", "ok"}: + return "good" + if clean in {"failed", "blocked", "error", "missing"}: + return "bad" + return "warn" + + +def _metric(label: str, value: Any, detail: str = "") -> str: + return ( + "
" + f"{_esc(label)}" + f"{_esc(value)}" + f"{_esc(detail)}" + "
" + ) + + +def _display_status(value: Any) -> str: + return str(value if value is not None else "").replace("_", " ") + + +def _section_table(caption: str, rows: list[tuple[str, Any]]) -> str: + body = "".join(f"{_esc(label)}{_esc(value)}" for label, value in rows) + return f"{body}
{_esc(caption)}
" + + +def _evidence_html(links: tuple[EvidenceLink, ...]) -> str: + items = [] + for link in links: + status = "available" if link.exists else "missing" + if link.exists: + items.append( + "
  • " + f"{_esc(link.label)}" + f"{_esc(link.kind)} - {_esc(status)} - {_esc(link.path)}" + "
  • " + ) + else: + items.append( + "
  • " + f"{_esc(link.label)}" + f"{_esc(link.kind)} - {_esc(status)} - {_esc(link.path)}" + "
  • " + ) + return "
      " + "".join(items) + "
    " + + +def render_patch_swarm_html(console_data: PatchSwarmConsoleData, output_dir: Path) -> Path: + resolved = normalize_run_dir(output_dir) + resolved.mkdir(parents=True, exist_ok=True) + path = resolved / "start-here.html" + result = str(console_data.current_run.get("result") or "unknown") + css = """ + :root { color-scheme: dark; --bg: #101214; --panel: #181c20; --text: #f4f1eb; --muted: #aeb6bd; --line: #303840; --good: #2fc483; --warn: #e0b84e; --bad: #f07167; --accent: #65b7ff; } + * { box-sizing: border-box; } + body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 Arial, Helvetica, sans-serif; } + header, main { max-width: 1180px; margin: 0 auto; padding: 24px; } + header { display: grid; gap: 12px; border-bottom: 1px solid var(--line); } + h1, h2 { margin: 0; line-height: 1.15; letter-spacing: 0; } + h1 { font-size: 2rem; } + h2 { font-size: 1.2rem; } + p { margin: 0; color: var(--muted); } + .badge { display: inline-flex; width: fit-content; border: 1px solid var(--line); padding: 4px 9px; font-weight: 700; text-transform: uppercase; font-size: 0.76rem; } + .badge.good { color: var(--good); border-color: rgba(47,196,131,.55); } + .badge.warn { color: var(--warn); border-color: rgba(224,184,78,.55); } + .badge.bad { color: var(--bad); border-color: rgba(240,113,103,.55); } + .nextAction { padding: 14px 16px; background: #1d2429; border-left: 4px solid var(--accent); } + main { display: grid; gap: 18px; } + section { border: 1px solid var(--line); background: var(--panel); padding: 18px; } + .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 10px; } + article { min-width: 0; padding: 13px; border: 1px solid var(--line); background: #11161a; } + article span, caption { display: block; color: var(--muted); font-size: .74rem; font-weight: 700; text-transform: uppercase; text-align: left; } + article strong { display: block; margin-top: 4px; font-size: 1.3rem; overflow-wrap: anywhere; } + article small { display: block; margin-top: 3px; color: var(--muted); overflow-wrap: anywhere; } + .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; } + table { width: 100%; border-collapse: collapse; margin-top: 10px; } + th, td { padding: 9px 10px; border-top: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; } + th { width: 42%; color: var(--muted); font-weight: 700; } + a { color: var(--accent); } + .evidenceList { list-style: none; padding: 0; margin: 12px 0 0; display: grid; gap: 8px; } + .evidenceList li { display: grid; gap: 2px; padding: 10px; border: 1px solid var(--line); background: #11161a; } + .evidenceList span, .evidenceList em { color: var(--muted); font-style: normal; overflow-wrap: anywhere; } + .evidenceList .missing { opacity: .72; } + .warnings { color: var(--warn); } + @media (max-width: 760px) { header, main { padding: 16px; } h1 { font-size: 1.55rem; } section { padding: 14px; } } + """ + html_text = f""" + + + + + Patch Swarm Console - {_esc(console_data.run_id)} + + + +
    + {_esc(result)} +

    Patch Swarm Current Run: {_esc(console_data.run_id)}

    +

    {_esc(console_data.run_dir)}

    +
    Next Action

    {_esc(console_data.next_action)}

    +
    +
    +
    +

    Patch Swarm Summary

    +
    + {_metric("Candidate Count", console_data.candidate_count)} + {_metric("Active Workers", console_data.workers.active_workers, f"max observed {console_data.workers.max_observed_parallel_workers}")} + {_metric("Accepted Bundles", console_data.bundles.accepted)} + {_metric("Rejected Bundles", console_data.bundles.rejected)} + {_metric("Integration Status", _display_status(console_data.integration.result))} + {_metric("Validation Status", _display_status(console_data.validation.result))} + {_metric("Release Candidate", _display_status(console_data.release_candidate.status))} +
    +
    +
    +

    Current Run

    + {_section_table("Current run details", list(console_data.current_run.items()) + [("generated_at", console_data.generated_at)])} +
    +
    +

    Task Graph

    +
    + {_metric("Tasks", console_data.task_graph.total_tasks)} + {_metric("Dependency Edges", console_data.task_graph.dependency_edges)} + {_metric("Root Tasks", console_data.task_graph.root_tasks)} + {_metric("Blocked Tasks", console_data.task_graph.blocked_tasks)} + {_metric("Conflict Tasks", console_data.task_graph.conflict_tasks)} +
    +
    +
    +

    Workers

    + {_section_table("Worker summary", [ + ("simulated", console_data.workers.simulated), + ("total_workers", console_data.workers.total_workers), + ("active_workers", console_data.workers.active_workers), + ("wave_count", console_data.workers.wave_count), + ("max_parallel_agents", console_data.workers.max_parallel_agents), + ("max_observed_parallel_workers", console_data.workers.max_observed_parallel_workers), + ("bounded_parallelism_passed", console_data.workers.bounded_parallelism_passed), + ])} +
    +
    +

    Bundles

    +
    + {_metric("Pending", console_data.bundles.pending)} + {_metric("Accepted", console_data.bundles.accepted)} + {_metric("Rejected", console_data.bundles.rejected)} + {_metric("Safe Apply", console_data.bundles.safe_apply)} + {_metric("Needs Rebase", console_data.bundles.needs_rebase)} + {_metric("Needs Human Review", console_data.bundles.needs_human_review)} + {_metric("Reject", console_data.bundles.reject)} +
    +
    +
    +

    Integration

    + {_section_table("Integration status", [ + ("result", console_data.integration.result), + ("groups", console_data.integration.groups), + ("conflicts", console_data.integration.conflicts), + ("safe_apply", console_data.integration.safe_apply), + ("needs_rebase", console_data.integration.needs_rebase), + ("needs_human_review", console_data.integration.needs_human_review), + ("reject", console_data.integration.reject), + ("conflict_report_path", console_data.integration.conflict_report_path or ""), + ])} +
    +
    +

    Validation

    + {_section_table("Validation status", [ + ("result", console_data.validation.result), + ("passed_gates", console_data.validation.passed_gates), + ("failed_gates", console_data.validation.failed_gates), + ("failing_gates", ", ".join(console_data.validation.failing_gates)), + ("report_path", console_data.validation.report_path or ""), + ])} +
    +
    +

    Evidence

    + {_evidence_html(console_data.evidence_links)} +
    +
    +

    Release Candidate

    + {_section_table("Release candidate status", [ + ("created", console_data.release_candidate.created), + ("status", console_data.release_candidate.status), + ("path", console_data.release_candidate.path or ""), + ("demo_evidence_path", console_data.release_candidate.demo_evidence_path or ""), + ])} +
    +
    +

    Raw JSON Links

    +

    Use console-data.json for stable machine-readable status.

    +
    +
    +

    Warnings

    +

    {_esc('; '.join(console_data.warnings) if console_data.warnings else 'No console aggregation warnings.')}

    +
    +
    + + +""" + path.write_text(html_text, encoding="utf-8") + return path + + +class _LinkParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.links: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "a": + return + values = dict(attrs) + href = values.get("href") + if href: + self.links.append(href) + + +def validate_console_links(run_dir: Path, html_path: Path) -> dict[str, Any]: + resolved_run_dir = normalize_run_dir(run_dir) + resolved_html = normalize_run_dir(html_path) + parser = _LinkParser() + parser.feed(resolved_html.read_text(encoding="utf-8")) + checked: list[dict[str, Any]] = [] + missing: list[str] = [] + escaped: list[str] = [] + external: list[str] = [] + for href in parser.links: + parsed = urlparse(href) + if parsed.scheme or parsed.netloc: + external.append(href) + continue + if href.startswith("#"): + continue + target = (resolved_html.parent / unquote(parsed.path)).resolve() + if resolved_run_dir not in target.parents and target != resolved_run_dir: + escaped.append(href) + continue + exists = target.exists() + checked.append({"href": href, "exists": exists}) + if not exists: + missing.append(href) + result = { + "checked": checked, + "missing": missing, + "escaped": escaped, + "external": external, + "passed": not missing and not escaped and not external, + } + (resolved_run_dir / "link-check.json").write_text(stable_json(result, pretty=True), encoding="utf-8") + return result + + +def emit_console_json(console_data: PatchSwarmConsoleData, *, output_dir: Path | None = None) -> dict[str, Any]: + out = normalize_run_dir(output_dir) if output_dir else normalize_run_dir(Path(console_data.run_dir)) + return { + "run_id": console_data.run_id, + "result": console_data.current_run.get("result", "unknown"), + "next_action": console_data.next_action, + "candidate_count": console_data.candidate_count, + "workers": { + "active_workers": console_data.workers.active_workers, + "max_observed_parallel_workers": console_data.workers.max_observed_parallel_workers, + "max_parallel_agents": console_data.workers.max_parallel_agents, + }, + "bundles": asdict(console_data.bundles), + "integration": { + "result": console_data.integration.result, + "groups": console_data.integration.groups, + "conflicts": console_data.integration.conflicts, + }, + "validation": { + "result": console_data.validation.result, + "failed_gates": console_data.validation.failed_gates, + }, + "release_candidate": { + "created": console_data.release_candidate.created, + "status": console_data.release_candidate.status, + }, + "artifacts": { + "run_dir": console_data.run_dir, + "start_here": rel(out / "start-here.html"), + "console_data": rel(out / "console-data.json"), + }, + } + + +def render_console( + run_dir: Path, + *, + output_dir: Path | None = None, + write_html: bool = False, + strict_links: bool = False, +) -> tuple[PatchSwarmConsoleData, dict[str, Any]]: + resolved_run_dir = normalize_run_dir(run_dir) + resolved_output_dir = normalize_run_dir(output_dir or run_dir) + data = collect_patch_swarm_console_data(resolved_run_dir) + write_console_data(data, resolved_output_dir) + html_path: Path | None = None + link_check: dict[str, Any] | None = None + if write_html: + html_path = render_patch_swarm_html(data, resolved_output_dir) + link_check = validate_console_links(resolved_output_dir, html_path) + if strict_links and not link_check.get("passed"): + raise RuntimeError("console link validation failed") + metadata = { + "console_data": rel(resolved_output_dir / "console-data.json"), + "start_here": rel(html_path) if html_path else "", + "link_check": link_check or {}, + } + return data, metadata + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Render Patch Swarm console data from run artifacts.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--output-dir", default="") + parser.add_argument("--write-html", action="store_true") + parser.add_argument("--strict-links", action="store_true") + parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + data, _metadata = render_console( + Path(args.run_dir), + output_dir=Path(args.output_dir) if args.output_dir else None, + write_html=args.write_html, + strict_links=args.strict_links, + ) + except RuntimeError as exc: + print(str(exc)) + return 1 + if args.json: + print(stable_json(emit_console_json(data, output_dir=Path(args.output_dir) if args.output_dir else Path(args.run_dir)), pretty=False), end="") + else: + print(f"{data.current_run.get('result', 'unknown')} {data.candidate_count} candidates {data.run_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_planner.py b/scripts/parallel_delivery_planner.py new file mode 100644 index 0000000..fd54ecc --- /dev/null +++ b/scripts/parallel_delivery_planner.py @@ -0,0 +1,1415 @@ +#!/usr/bin/env python3 +"""Patch Swarm request splitter and bounded task planner. + +This module creates durable planner artifacts for Patch Swarm runs. It writes +split-plan and task-graph contracts only; it does not dispatch workers, call +live models by default, apply patches, or mutate Taskstream state. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter, deque +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +try: + import parallel_delivery_artifacts as artifact_schema +except ImportError: # pragma: no cover - direct import fallback for unusual cwd + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import parallel_delivery_artifacts as artifact_schema + + +ROOT = Path(__file__).resolve().parents[1] +RUNS_ROOT = ROOT / "workspace" / "runs" / "parallel-delivery" +PRODUCER = "cento.parallel-delivery.planner" +CURRENT_SCHEMA_VERSION = artifact_schema.CURRENT_SCHEMA_VERSION +MAX_CANDIDATE_TASKS = 100 + +PLANNER_MODES = {"fixture", "no-model", "proreq", "manual-import"} +TASK_LANES = {"builder", "validator", "docs-evidence", "coordinator", "integrator", "human-handoff"} +RISK_TIERS = {"low", "medium", "high", "human"} +WORKER_PROFILES = { + "python-builder", + "cli-builder", + "schema-validator", + "test-writer", + "docs-evidence-writer", + "safe-integrator", + "factory-planner", + "workset-lease-planner", + "human-operator", +} +EDGE_TYPES = set(artifact_schema.EDGE_TYPES) +TASK_STATES = set(artifact_schema.TASK_STATES) + +LANE_CYCLE = [ + ("coordinator", "factory-planner", "medium"), + ("builder", "python-builder", "medium"), + ("validator", "test-writer", "low"), + ("docs-evidence", "docs-evidence-writer", "low"), + ("integrator", "safe-integrator", "high"), +] + +SUBJECTIVE_OR_UNSAFE_KEYWORDS = [ + "visual polish", + "looks good", + "try on device", + "production credentials", + "browser-only", + "manual approval", + "deploy to prod", + "real customer", + "secret", + "token", + ".env", +] + + +class PlannerValidationError(Exception): + """Raised when planner input or output is invalid.""" + + +@dataclass(frozen=True) +class PlannerRequest: + request_text: str + request_file: str | None + run_id: str + run_dir: Path + mode: str + candidate_target: int + max_parallel_agents: int + live_pro: bool = False + import_plan: Path | None = None + dry_run: bool = False + command: str = "patch-swarm split" + timestamp: str | None = None + + +@dataclass(frozen=True) +class PlannerResult: + run_id: str + run_dir: Path + mode: str + candidate_target: int + candidate_count: int + max_parallel_agents: int + split_plan: dict[str, Any] + task_graph: dict[str, Any] + artifacts: list[str] + warnings: list[str] + errors: list[str] + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json_dumps(payload: dict[str, Any]) -> str: + """Return deterministic JSON with sorted keys, two-space indent, and trailing newline.""" + return artifact_schema.stable_json_dumps(payload) + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + """Write deterministic JSON artifact.""" + artifact_schema.write_json_artifact(path, payload) + + +def rel(path: Path) -> str: + try: + return str(path.resolve().relative_to(ROOT)) + except ValueError: + return str(path) + + +def _timestamp(value: str | None) -> str: + return value or utc_now() + + +def _provenance(command: str, mode: str, source: str) -> dict[str, Any]: + return { + "command": command, + "mode": mode, + "notes": [], + "producer": PRODUCER, + "repo": "cento", + "source": source, + } + + +def _common(artifact_type: str, request: PlannerRequest, timestamp: str, source: str) -> dict[str, Any]: + return { + "artifact_type": artifact_type, + "created_at": timestamp, + "evidence_pointers": [], + "provenance": _provenance(request.command, request.mode, source), + "run_id": request.run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + } + + +def _metadata_comment(artifact_type: str, run_id: str, timestamp: str) -> str: + metadata = { + "artifact_type": artifact_type, + "created_at": timestamp, + "run_id": run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + } + return f"" + + +def read_request_text(request_file: Path | None, fallback: str | None = None) -> str: + """Read request text safely; fail clearly if required input is missing.""" + if request_file: + path = request_file if request_file.is_absolute() else ROOT / request_file + if not path.exists(): + raise PlannerValidationError(f"request file does not exist: {request_file}") + if any(part == ".env.mcp" for part in path.parts): + raise PlannerValidationError("request file must not point to .env.mcp") + return path.read_text(encoding="utf-8") + if fallback and fallback.strip(): + return fallback + raise PlannerValidationError("request-file or request text is required for this planner mode") + + +def validate_candidate_target(candidate_target: int) -> int: + """Require 1 <= candidate_target <= 100.""" + if not isinstance(candidate_target, int): + raise PlannerValidationError("candidate_target must be an integer") + if not 1 <= candidate_target <= MAX_CANDIDATE_TASKS: + raise PlannerValidationError("candidate_target must be between 1 and 100") + return candidate_target + + +def validate_max_parallel_agents(max_parallel_agents: int, candidate_target: int) -> int: + """Require 1 <= max_parallel_agents <= candidate_target.""" + if not isinstance(max_parallel_agents, int): + raise PlannerValidationError("max_parallel_agents must be an integer") + if not 1 <= max_parallel_agents <= candidate_target: + raise PlannerValidationError("max_parallel_agents must be between 1 and candidate_target") + return max_parallel_agents + + +def normalize_mode(args: argparse.Namespace) -> str: + """Resolve --mode plus shorthand flags into fixture/no-model/proreq/manual-import.""" + shorthand = [ + ("fixture", bool(getattr(args, "fixture", False))), + ("no-model", bool(getattr(args, "no_model", False))), + ("proreq", bool(getattr(args, "proreq", False))), + ("manual-import", bool(getattr(args, "manual_import", False))), + ] + selected = [mode for mode, enabled in shorthand if enabled] + if len(selected) > 1: + raise PlannerValidationError(f"planner mode shorthands conflict: {', '.join(selected)}") + explicit = getattr(args, "mode", "") or "" + if selected and explicit and explicit != selected[0]: + raise PlannerValidationError(f"--mode {explicit} conflicts with --{selected[0]}") + mode = selected[0] if selected else explicit or "fixture" + if mode not in PLANNER_MODES: + raise PlannerValidationError(f"planner mode must be one of {', '.join(sorted(PLANNER_MODES))}") + return mode + + +def normalize_relative_path(path: str) -> str: + """Normalize a path and reject absolute paths, '..', .env.mcp, and secret-like paths.""" + value = str(path).replace("\\", "/").strip() + value = re.sub(r"/+", "/", value).rstrip("/") + errors = artifact_schema.validate_relative_artifact_path(value) + if errors: + raise PlannerValidationError("; ".join(errors)) + return value + + +def _path_errors(path: str) -> list[str]: + try: + normalize_relative_path(path) + return [] + except PlannerValidationError as exc: + return [str(exc)] + + +def _normalize_path_list(paths: Any, field: str) -> list[str]: + if paths is None: + return [] + if not isinstance(paths, list): + raise PlannerValidationError(f"{field} must be a list") + normalized = [] + for item in paths: + if not isinstance(item, str): + raise PlannerValidationError(f"{field} entries must be strings") + normalized.append(normalize_relative_path(item)) + return sorted(dict.fromkeys(normalized)) + + +def validate_non_overlapping_owned_paths(tasks: list[dict[str, Any]]) -> list[str]: + """Return errors for duplicate or overlapping owned paths.""" + errors: list[str] = [] + owned: list[tuple[str, str]] = [] + for task in tasks: + task_id = str(task.get("task_id", "unknown")) + for path in task.get("owned_paths", []): + owned.append((task_id, str(path).rstrip("/"))) + for index, (task_a, path_a) in enumerate(owned): + for task_b, path_b in owned[index + 1 :]: + if path_a == path_b: + errors.append(f"owned path overlap: {task_a} and {task_b} both own {path_a}") + elif path_a.startswith(path_b + "/") or path_b.startswith(path_a + "/"): + errors.append(f"owned path prefix overlap: {task_a}:{path_a} and {task_b}:{path_b}") + return errors + + +def validate_planned_task(task: dict[str, Any]) -> list[str]: + """Validate required task fields, lane, risk tier, acceptance contract, validation commands.""" + errors: list[str] = [] + required = [ + "task_id", + "title", + "story", + "summary", + "lane", + "state", + "risk_tier", + "human_handoff", + "worker_profile", + "owned_paths", + "read_only_paths", + "dependencies", + "acceptance_contract", + "validation_commands", + "expected_artifacts", + "integration_notes", + "rejection_triggers", + "evidence_pointers", + ] + for field in required: + if field not in task: + errors.append(f"{task.get('task_id', 'task')}: missing {field}") + if task.get("lane") not in TASK_LANES: + errors.append(f"{task.get('task_id', 'task')}: lane must be known") + if task.get("risk_tier") not in RISK_TIERS: + errors.append(f"{task.get('task_id', 'task')}: risk_tier must be known") + if task.get("worker_profile") not in WORKER_PROFILES: + errors.append(f"{task.get('task_id', 'task')}: worker_profile must be known") + if task.get("state") not in TASK_STATES: + errors.append(f"{task.get('task_id', 'task')}: state must be known") + if not isinstance(task.get("human_handoff"), bool): + errors.append(f"{task.get('task_id', 'task')}: human_handoff must be boolean") + for field in [ + "owned_paths", + "read_only_paths", + "dependencies", + "acceptance_contract", + "validation_commands", + "expected_artifacts", + "integration_notes", + "rejection_triggers", + "evidence_pointers", + ]: + if field in task and not isinstance(task[field], list): + errors.append(f"{task.get('task_id', 'task')}: {field} must be a list") + for field in ["owned_paths", "read_only_paths"]: + if isinstance(task.get(field), list): + for path in task[field]: + errors.extend(f"{task.get('task_id', 'task')}.{field}: {error}" for error in _path_errors(str(path))) + if not task.get("human_handoff"): + if not task.get("acceptance_contract"): + errors.append(f"{task.get('task_id', 'task')}: non-human tasks require acceptance_contract") + if not task.get("validation_commands"): + errors.append(f"{task.get('task_id', 'task')}: non-human tasks require validation_commands") + if task.get("human_handoff") and task.get("worker_profile") != "human-operator": + errors.append(f"{task.get('task_id', 'task')}: human handoff tasks must use human-operator") + return errors + + +def validate_split_plan(plan: dict[str, Any]) -> list[str]: + """Validate split-plan schema and planner-specific rules.""" + errors = artifact_schema.validate_split_plan(plan) + if plan.get("artifact_type") != "split-plan": + errors.append("split-plan artifact_type must be split-plan") + for field in ["candidate_target", "candidate_count", "max_parallel_agents", "planner_mode", "planning_policy", "lanes"]: + if field not in plan: + errors.append(f"split-plan missing {field}") + if plan.get("planner_mode") not in PLANNER_MODES: + errors.append("split-plan planner_mode must be known") + candidate_target = plan.get("candidate_target") + candidate_count = plan.get("candidate_count") + max_parallel_agents = plan.get("max_parallel_agents") + if not isinstance(candidate_target, int) or not 1 <= candidate_target <= MAX_CANDIDATE_TASKS: + errors.append("split-plan.candidate_target must be between 1 and 100") + if not isinstance(candidate_count, int) or not 1 <= candidate_count <= MAX_CANDIDATE_TASKS: + errors.append("split-plan.candidate_count must be between 1 and 100") + if isinstance(candidate_count, int) and isinstance(candidate_target, int) and candidate_count > candidate_target: + errors.append("split-plan.candidate_count must not exceed candidate_target") + if not isinstance(max_parallel_agents, int) or ( + isinstance(candidate_target, int) and not 1 <= max_parallel_agents <= candidate_target + ): + errors.append("split-plan.max_parallel_agents must be between 1 and candidate_target") + tasks = plan.get("tasks") + if not isinstance(tasks, list) or not tasks: + return errors + ["split-plan.tasks must be a non-empty list"] + if isinstance(candidate_count, int) and len(tasks) != candidate_count: + errors.append("split-plan.candidate_count must equal tasks length") + seen: set[str] = set() + for task in tasks: + if not isinstance(task, dict): + errors.append("split-plan.tasks entries must be objects") + continue + errors.extend(validate_planned_task(task)) + task_id = str(task.get("task_id", "")) + if task_id in seen: + errors.append(f"duplicate task_id {task_id}") + seen.add(task_id) + for task in tasks: + if isinstance(task, dict): + for dependency in task.get("dependencies", []): + if dependency not in seen: + errors.append(f"{task.get('task_id')}: unknown dependency {dependency}") + errors.extend(validate_non_overlapping_owned_paths([task for task in tasks if isinstance(task, dict)])) + return errors + + +def _depends_on_edges_from_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, str]]: + edges: list[dict[str, str]] = [] + for task in tasks: + for dependency in task.get("dependencies", []): + edges.append( + { + "from": dependency, + "reason": f"{task['task_id']} consumes {dependency} output", + "to": task["task_id"], + "type": "depends_on", + } + ) + return edges + + +def _topological_order(task_ids: list[str], edges: list[dict[str, str]]) -> list[str]: + outgoing: dict[str, list[str]] = {task_id: [] for task_id in task_ids} + incoming_count: dict[str, int] = {task_id: 0 for task_id in task_ids} + for edge in edges: + if edge.get("type") != "depends_on": + continue + source = edge["from"] + target = edge["to"] + if source not in incoming_count: + raise PlannerValidationError(f"unknown dependency {source}") + if target not in incoming_count: + raise PlannerValidationError(f"unknown dependency target {target}") + outgoing.setdefault(source, []).append(target) + incoming_count[target] = incoming_count.get(target, 0) + 1 + ready = deque(sorted(task_id for task_id in task_ids if incoming_count.get(task_id, 0) == 0)) + order: list[str] = [] + while ready: + task_id = ready.popleft() + order.append(task_id) + for target in sorted(outgoing.get(task_id, [])): + incoming_count[target] -= 1 + if incoming_count[target] == 0: + ready.append(target) + if len(order) != len(task_ids): + raise PlannerValidationError("depends_on graph must be acyclic") + return order + + +def _parallel_groups( + order: list[str], + tasks_by_id: dict[str, dict[str, Any]], + max_parallel_agents: int, +) -> list[dict[str, Any]]: + groups: list[dict[str, Any]] = [] + current: list[str] = [] + for task_id in order: + task = tasks_by_id[task_id] + if task.get("human_handoff"): + if current: + groups.append({"automated": True, "group_id": f"group-{len(groups) + 1:04d}", "task_ids": current}) + current = [] + groups.append({"automated": False, "group_id": f"group-{len(groups) + 1:04d}", "task_ids": [task_id]}) + continue + current.append(task_id) + if len(current) >= max_parallel_agents: + groups.append({"automated": True, "group_id": f"group-{len(groups) + 1:04d}", "task_ids": current}) + current = [] + if current: + groups.append({"automated": True, "group_id": f"group-{len(groups) + 1:04d}", "task_ids": current}) + return groups + + +def build_task_graph(split_plan: dict[str, Any], *, max_parallel_agents: int) -> dict[str, Any]: + """Build task graph, topological order, and parallel groups.""" + tasks = split_plan["tasks"] + task_ids = [task["task_id"] for task in tasks] + depends_edges = _depends_on_edges_from_tasks(tasks) + context_edges: list[dict[str, str]] = [] + last_builder = "" + for task in tasks: + if task["lane"] == "builder": + last_builder = task["task_id"] + if task["lane"] == "docs-evidence" and last_builder: + context_edges.append( + { + "from": last_builder, + "reason": f"{task['task_id']} shares context with builder output", + "to": task["task_id"], + "type": "shares_context", + } + ) + order = _topological_order(task_ids, depends_edges) + tasks_by_id = {task["task_id"]: task for task in tasks} + graph = { + "artifact_type": "task-graph", + "created_at": split_plan["created_at"], + "edges": depends_edges + context_edges, + "evidence_pointers": [], + "max_parallel_agents": max_parallel_agents, + "nodes": [ + { + "human_handoff": task["human_handoff"], + "lane": task["lane"], + "owned_paths": task["owned_paths"], + "risk_tier": task["risk_tier"], + "task_id": task["task_id"], + } + for task in tasks + ], + "parallel_groups": _parallel_groups(order, tasks_by_id, max_parallel_agents), + "provenance": split_plan["provenance"], + "run_id": split_plan["run_id"], + "schema_version": CURRENT_SCHEMA_VERSION, + "topological_order": order, + "updated_at": split_plan["updated_at"], + } + return graph + + +def validate_task_graph(graph: dict[str, Any], split_plan: dict[str, Any]) -> list[str]: + """Validate nodes, edges, acyclicity, topological order, and parallel group width.""" + errors = artifact_schema.validate_task_graph(graph) + task_ids = [task["task_id"] for task in split_plan.get("tasks", []) if isinstance(task, dict)] + task_set = set(task_ids) + graph_nodes = [node.get("task_id") for node in graph.get("nodes", []) if isinstance(node, dict)] + if set(graph_nodes) != task_set: + errors.append("task-graph nodes must match split-plan tasks") + order = graph.get("topological_order") + if not isinstance(order, list) or set(order) != task_set or len(order) != len(task_ids): + errors.append("task-graph topological_order must include every task exactly once") + else: + positions = {task_id: index for index, task_id in enumerate(order)} + for edge in graph.get("edges", []): + if isinstance(edge, dict) and edge.get("type") == "depends_on": + if positions.get(edge.get("from"), 0) > positions.get(edge.get("to"), 0): + errors.append(f"depends_on order violation: {edge.get('from')} -> {edge.get('to')}") + max_parallel_agents = graph.get("max_parallel_agents") + for group in graph.get("parallel_groups", []): + if not isinstance(group, dict): + errors.append("parallel_groups entries must be objects") + continue + task_group = group.get("task_ids") + if not isinstance(task_group, list): + errors.append("parallel_groups.task_ids must be a list") + continue + if isinstance(max_parallel_agents, int) and len(task_group) > max_parallel_agents: + errors.append("parallel group exceeds max_parallel_agents") + for task_id in task_group: + if task_id not in task_set: + errors.append(f"parallel group references unknown task {task_id}") + return errors + + +def _request_title(text: str, fallback: str) -> str: + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + return stripped.lstrip("#").strip() or fallback + if stripped: + return stripped[:80] + return fallback + + +def _task( + index: int, + *, + title: str, + summary: str, + lane: str, + risk_tier: str, + worker_profile: str, + owned_paths: list[str], + read_only_paths: list[str], + dependencies: list[str] | None = None, + human_handoff: bool = False, + validation_commands: list[str] | None = None, + acceptance_contract: list[str] | None = None, + expected_artifacts: list[str] | None = None, + integration_notes: list[str] | None = None, + rejection_triggers: list[str] | None = None, +) -> dict[str, Any]: + task_id = f"task-{index:04d}" + if human_handoff: + worker_profile = "human-operator" + risk_tier = "human" + lane = "human-handoff" + return { + "acceptance_contract": acceptance_contract + or [ + "Required artifacts are written under the task's owned paths.", + "No unrelated dirty work or secret-like paths are touched.", + "Validation commands complete or exact failure evidence is recorded.", + ], + "dependencies": dependencies or [], + "evidence_pointers": [], + "expected_artifacts": expected_artifacts or [f"task-contracts/{task_id}.md"], + "human_handoff": human_handoff, + "integration_notes": integration_notes + or ["Planner output only; later Safe Integrator calls decide apply order."], + "lane": lane, + "owned_paths": _normalize_path_list(owned_paths, f"{task_id}.owned_paths"), + "read_only_paths": _normalize_path_list(read_only_paths, f"{task_id}.read_only_paths"), + "rejection_triggers": rejection_triggers + or [ + "Touches an unowned path.", + "Drops required acceptance or validation evidence.", + "Introduces live dispatch, patch apply, or secret copying.", + ], + "risk_tier": risk_tier, + "state": "created", + "story": f"As a Cento operator, I need {summary.rstrip('.').lower()} so Patch Swarm can proceed with bounded evidence.", + "summary": summary, + "task_id": task_id, + "title": title, + "validation_commands": validation_commands + or [ + "python3 -m json.tool data/tools.json >/dev/null", + "cento docs parallel-delivery >/tmp/cento-parallel-delivery-docs-check.txt", + ], + "worker_profile": worker_profile, + } + + +def create_fixture_tasks(candidate_target: int, run_dir: Path) -> list[dict[str, Any]]: + """Create deterministic fixture tasks for 5/20/100 and other valid target counts.""" + validate_candidate_target(candidate_target) + tasks: list[dict[str, Any]] = [] + last_validator = "" + for index in range(1, candidate_target + 1): + lane, profile, risk = LANE_CYCLE[(index - 1) % len(LANE_CYCLE)] + task_id = f"task-{index:04d}" + dependencies: list[str] = [] + if lane == "integrator" and last_validator: + dependencies.append(last_validator) + if lane == "validator": + last_validator = task_id + title = f"Planner fixture {task_id} {lane} lane" + summary = f"Produce deterministic {lane} planner evidence for {task_id}." + tasks.append( + _task( + index, + title=title, + summary=summary, + lane=lane, + risk_tier=risk, + worker_profile=profile, + owned_paths=[f"workspace/runs/parallel-delivery/planner-fixture/task-work/{task_id}/"], + read_only_paths=["docs/patch-swarm.md", "docs/parallel-delivery/patch-swarm-artifacts.md"], + dependencies=dependencies, + expected_artifacts=[f"task-contracts/{task_id}.md", f"task-work/{task_id}/evidence.json"], + validation_commands=[ + f"test -f workspace/runs/parallel-delivery/planner-fixture/task-contracts/{task_id}.md", + "python3 -m json.tool workspace/runs/parallel-delivery/planner-fixture/split-plan.json >/dev/null", + ], + ) + ) + return tasks + + +def _source_for_mode(mode: str) -> str: + return {"fixture": "fixture", "manual-import": "manual-import", "no-model": "local", "proreq": "local"}[mode] + + +def _build_split_plan(request: PlannerRequest, tasks: list[dict[str, Any]], timestamp: str) -> dict[str, Any]: + title = _request_title(request.request_text, "Patch Swarm planner request") + split_plan = { + **_common("split-plan", request, timestamp, _source_for_mode(request.mode)), + "candidate_count": len(tasks), + "candidate_target": request.candidate_target, + "lanes": sorted(TASK_LANES), + "max_candidate_tasks": request.candidate_target, + "max_parallel_agents": request.max_parallel_agents, + "planner_mode": request.mode, + "planning_policy": { + "avoid_overlapping_owned_paths": True, + "coarse_lanes_first": True, + "do_not_blindly_fill_to_target": request.mode != "fixture", + "human_handoff_for_subjective_or_device_bound": True, + }, + "request": { + "request_file": request.request_file or "request.md", + "summary": request.request_text.strip()[:240], + "title": title, + }, + "tasks": tasks, + "updated_at": timestamp, + } + return split_plan + + +def _validate_or_raise(split_plan: dict[str, Any], task_graph: dict[str, Any]) -> None: + errors = validate_split_plan(split_plan) + errors.extend(validate_task_graph(task_graph, split_plan)) + if errors: + raise PlannerValidationError("; ".join(errors)) + + +def plan_fixture(request: PlannerRequest) -> PlannerResult: + """Create deterministic fixture split plan and graph.""" + timestamp = _timestamp(request.timestamp) + tasks = create_fixture_tasks(request.candidate_target, request.run_dir) + split_plan = _build_split_plan(request, tasks, timestamp) + task_graph = build_task_graph(split_plan, max_parallel_agents=request.max_parallel_agents) + _validate_or_raise(split_plan, task_graph) + result = PlannerResult( + artifacts=[], + candidate_count=len(tasks), + candidate_target=request.candidate_target, + errors=[], + max_parallel_agents=request.max_parallel_agents, + mode=request.mode, + run_dir=request.run_dir, + run_id=request.run_id, + split_plan=split_plan, + task_graph=task_graph, + warnings=[], + ) + return write_planner_artifacts(result) + + +def _keyword_hits(text: str) -> set[str]: + lowered = text.lower() + hits: set[str] = set() + keyword_map = { + "builder": ["code", "implement", "script", "python", "helper", "schema", "planner", "split"], + "cli": ["cli", "command", "help", "flag", "argument", "cento"], + "docs-evidence": ["doc", "docs", "readme", "evidence", "report", "summary"], + "validator": ["test", "validation", "validate", "fixture", "json"], + "integrator": ["integrate", "integration", "factory", "workset", "apply", "release"], + } + for key, values in keyword_map.items(): + if any(value in lowered for value in values): + hits.add(key) + if any(value in lowered for value in SUBJECTIVE_OR_UNSAFE_KEYWORDS): + hits.add("human") + return hits + + +def _no_model_tasks(request: PlannerRequest) -> list[dict[str, Any]]: + text = request.request_text + hits = _keyword_hits(text) + small = any(phrase in text.lower() for phrase in ["small request", "one existing cli", "help text", "clarification"]) + task_specs: list[dict[str, Any]] = [] + task_specs.append( + { + "lane": "coordinator", + "owned": ["workspace/runs/parallel-delivery/planner-output/coordinator/"], + "profile": "factory-planner", + "risk": "medium", + "summary": "Normalize the request into a bounded Patch Swarm planning contract.", + "title": "Normalize planner scope and surfaces", + } + ) + if hits & {"builder", "cli"}: + task_specs.append( + { + "lane": "builder", + "owned": ["scripts/parallel_delivery_planner.py"], + "profile": "python-builder" if "builder" in hits else "cli-builder", + "risk": "medium", + "summary": "Implement the bounded planner or CLI routing slice requested by the operator.", + "title": "Implement bounded planner surface", + } + ) + if "validator" in hits or not small: + task_specs.append( + { + "lane": "validator", + "owned": ["tests/test_parallel_delivery_planner.py"], + "profile": "test-writer", + "risk": "low", + "summary": "Validate planner counts, paths, graph ordering, and JSON responses.", + "title": "Add planner validation coverage", + } + ) + if "docs-evidence" in hits or small: + task_specs.append( + { + "lane": "docs-evidence", + "owned": ["docs/parallel-delivery/patch-swarm-planner.md"], + "profile": "docs-evidence-writer", + "risk": "low", + "summary": "Document planner modes, task contract fields, unsafe path rules, and evidence outputs.", + "title": "Document planner contract", + } + ) + if "integrator" in hits and not small: + task_specs.append( + { + "lane": "integrator", + "owned": ["workspace/runs/parallel-delivery/planner-output/integration/"], + "profile": "safe-integrator", + "risk": "high", + "summary": "Prepare integration sequencing guidance without applying patches.", + "title": "Plan safe integration sequencing", + } + ) + if "human" in hits: + task_specs.append( + { + "human": True, + "lane": "human-handoff", + "owned": [], + "profile": "human-operator", + "risk": "human", + "summary": "Record human review for subjective, credential-bound, or device-bound decisions.", + "title": "Human handoff for unsafe or subjective work", + } + ) + if small: + task_specs = task_specs[: min(len(task_specs), 5)] + task_specs = task_specs[: request.candidate_target] + tasks: list[dict[str, Any]] = [] + for index, spec in enumerate(task_specs, start=1): + human = bool(spec.get("human", False)) + tasks.append( + _task( + index, + title=str(spec["title"]), + summary=str(spec["summary"]), + lane=str(spec["lane"]), + risk_tier=str(spec["risk"]), + worker_profile=str(spec["profile"]), + owned_paths=list(spec["owned"]), + read_only_paths=["data/tools.json", "docs/patch-swarm.md", "scripts/parallel_delivery.py"], + dependencies=["task-0001"] if index > 1 and spec["lane"] in {"integrator", "docs-evidence"} else [], + human_handoff=human, + validation_commands=[] if human else None, + acceptance_contract=["Human operator decision is recorded with exact rationale."] if human else None, + expected_artifacts=[f"task-contracts/task-{index:04d}.md"], + ) + ) + return tasks or create_fixture_tasks(min(request.candidate_target, 3), request.run_dir) + + +def plan_no_model(request: PlannerRequest, repo_hints: dict[str, Any] | None = None) -> PlannerResult: + """Rule-based planner using request text and safe repo hints.""" + del repo_hints + timestamp = _timestamp(request.timestamp) + tasks = _no_model_tasks(request) + split_plan = _build_split_plan(request, tasks, timestamp) + task_graph = build_task_graph(split_plan, max_parallel_agents=request.max_parallel_agents) + _validate_or_raise(split_plan, task_graph) + result = PlannerResult( + artifacts=[], + candidate_count=len(tasks), + candidate_target=request.candidate_target, + errors=[], + max_parallel_agents=request.max_parallel_agents, + mode=request.mode, + run_dir=request.run_dir, + run_id=request.run_id, + split_plan=split_plan, + task_graph=task_graph, + warnings=[], + ) + return write_planner_artifacts(result) + + +def plan_proreq(request: PlannerRequest) -> PlannerResult: + """Emit ProReq planning prompt/manifest without live Pro call by default.""" + if request.live_pro: + raise PlannerValidationError("live Pro planning is not wired to a safe backend in this call; omit --live-pro") + seed_request = PlannerRequest( + candidate_target=request.candidate_target, + command=request.command, + dry_run=request.dry_run, + import_plan=request.import_plan, + live_pro=request.live_pro, + max_parallel_agents=request.max_parallel_agents, + mode="proreq", + request_file=request.request_file, + request_text=request.request_text, + run_dir=request.run_dir, + run_id=request.run_id, + timestamp=request.timestamp, + ) + timestamp = _timestamp(seed_request.timestamp) + tasks = _no_model_tasks(seed_request) + split_plan = _build_split_plan(seed_request, tasks, timestamp) + task_graph = build_task_graph(split_plan, max_parallel_agents=seed_request.max_parallel_agents) + _validate_or_raise(split_plan, task_graph) + result = PlannerResult( + artifacts=[], + candidate_count=len(tasks), + candidate_target=seed_request.candidate_target, + errors=[], + max_parallel_agents=seed_request.max_parallel_agents, + mode=seed_request.mode, + run_dir=seed_request.run_dir, + run_id=seed_request.run_id, + split_plan=split_plan, + task_graph=task_graph, + warnings=["live Pro was not called; prompt artifacts were generated for manual Pro planning."], + ) + return write_planner_artifacts(result) + + +def _normalize_imported_task(raw: dict[str, Any], index: int) -> dict[str, Any]: + lane = raw.get("lane", "builder") + human = bool(raw.get("human_handoff", False) or lane == "human-handoff") + profile = raw.get("worker_profile") or ("human-operator" if human else "python-builder") + risk = raw.get("risk_tier") or ("human" if human else "medium") + return _task( + index, + title=str(raw.get("title") or f"Imported task {index:04d}"), + summary=str(raw.get("summary") or raw.get("story") or f"Normalize imported task {index:04d}."), + lane=str(lane), + risk_tier=str(risk), + worker_profile=str(profile), + owned_paths=_normalize_path_list(raw.get("owned_paths", []), "owned_paths"), + read_only_paths=_normalize_path_list(raw.get("read_only_paths", []), "read_only_paths"), + dependencies=[str(item) for item in raw.get("dependencies", [])], + human_handoff=human, + validation_commands=[str(item) for item in raw.get("validation_commands", [])], + acceptance_contract=[str(item) for item in raw.get("acceptance_contract", [])], + expected_artifacts=[str(item) for item in raw.get("expected_artifacts", [f"task-contracts/task-{index:04d}.md"])], + integration_notes=[str(item) for item in raw.get("integration_notes", [])], + rejection_triggers=[str(item) for item in raw.get("rejection_triggers", [])], + ) + + +def plan_manual_import(request: PlannerRequest) -> PlannerResult: + """Validate and normalize an imported Pro-generated split plan.""" + if not request.import_plan: + raise PlannerValidationError("--import-plan is required for manual-import mode") + import_path = request.import_plan if request.import_plan.is_absolute() else ROOT / request.import_plan + try: + imported = json.loads(import_path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise PlannerValidationError(f"import plan not found: {request.import_plan}") from exc + except json.JSONDecodeError as exc: + raise PlannerValidationError(f"import plan invalid JSON: {exc.msg}") from exc + if not isinstance(imported, dict): + raise PlannerValidationError("import plan must be a JSON object") + raw_tasks = imported.get("tasks") + if not isinstance(raw_tasks, list) or not raw_tasks: + raise PlannerValidationError("import plan tasks must be a non-empty list") + if len(raw_tasks) > MAX_CANDIDATE_TASKS: + raise PlannerValidationError("import plan candidate_count must not exceed 100") + timestamp = _timestamp(request.timestamp) + tasks = [_normalize_imported_task(raw, index) for index, raw in enumerate(raw_tasks, start=1) if isinstance(raw, dict)] + if len(tasks) != len(raw_tasks): + raise PlannerValidationError("import plan tasks entries must be objects") + candidate_target = max(len(tasks), min(request.candidate_target, MAX_CANDIDATE_TASKS)) + normalized_request = PlannerRequest( + candidate_target=candidate_target, + command=request.command, + dry_run=request.dry_run, + import_plan=request.import_plan, + live_pro=request.live_pro, + max_parallel_agents=min(request.max_parallel_agents, candidate_target), + mode=request.mode, + request_file=request.request_file, + request_text=request.request_text or "Manual-import Patch Swarm split plan.", + run_dir=request.run_dir, + run_id=request.run_id, + timestamp=request.timestamp, + ) + split_plan = _build_split_plan(normalized_request, tasks, timestamp) + task_graph = build_task_graph(split_plan, max_parallel_agents=normalized_request.max_parallel_agents) + _validate_or_raise(split_plan, task_graph) + result = PlannerResult( + artifacts=[], + candidate_count=len(tasks), + candidate_target=candidate_target, + errors=[], + max_parallel_agents=normalized_request.max_parallel_agents, + mode=normalized_request.mode, + run_dir=normalized_request.run_dir, + run_id=normalized_request.run_id, + split_plan=split_plan, + task_graph=task_graph, + warnings=[], + ) + return write_planner_artifacts(result) + + +def write_task_contracts(run_dir: Path, tasks: list[dict[str, Any]]) -> list[str]: + """Write task-contracts/task-XXXX.md files.""" + contract_dir = run_dir / "task-contracts" + contract_dir.mkdir(parents=True, exist_ok=True) + paths: list[str] = [] + for task in tasks: + path = contract_dir / f"{task['task_id']}.md" + body = [ + f"# {task['task_id']} {task['title']}", + "", + f"Task ID: {task['task_id']}", + f"Title: {task['title']}", + f"Lane: {task['lane']}", + f"Risk tier: {task['risk_tier']}", + f"Worker profile: {task['worker_profile']}", + f"Human handoff: {str(task['human_handoff']).lower()}", + "", + "## Story", + task["story"], + "", + "## Owned Paths", + *([f"- `{item}`" for item in task["owned_paths"]] or ["- None"]), + "", + "## Read-Only Paths", + *([f"- `{item}`" for item in task["read_only_paths"]] or ["- None"]), + "", + "## Dependencies", + *([f"- `{item}`" for item in task["dependencies"]] or ["- None"]), + "", + "## Acceptance Contract", + *([f"- {item}" for item in task["acceptance_contract"]] or ["- Human decision required."]), + "", + "## Validation Commands", + *([f"- `{item}`" for item in task["validation_commands"]] or ["- Human handoff validation evidence required."]), + "", + "## Expected Artifacts", + *([f"- `{item}`" for item in task["expected_artifacts"]] or ["- None"]), + "", + "## Integration Notes", + *([f"- {item}" for item in task["integration_notes"]] or ["- None"]), + "", + "## Rejection Triggers", + *([f"- {item}" for item in task["rejection_triggers"]] or ["- None"]), + "", + "## Evidence To Produce", + "- Update task evidence pointers or run-scoped validation logs before closeout.", + "", + ] + path.write_text("\n".join(body), encoding="utf-8") + paths.append(rel(path)) + return paths + + +def _write_request_artifact(run_dir: Path, request: PlannerRequest, timestamp: str) -> str: + path = run_dir / "request.md" + body = [ + _metadata_comment("request", request.run_id, timestamp), + f"# {_request_title(request.request_text, 'Patch Swarm planner request')}", + "", + request.request_text.strip() or "Patch Swarm planner request.", + "", + ] + path.write_text("\n".join(body), encoding="utf-8") + return rel(path) + + +def _planner_report(result: PlannerResult) -> str: + lane_counts = Counter(task["lane"] for task in result.split_plan["tasks"]) + risk_counts = Counter(task["risk_tier"] for task in result.split_plan["tasks"]) + human = [task["task_id"] for task in result.split_plan["tasks"] if task["human_handoff"]] + lines = [ + "# Patch Swarm Planner Report", + "", + "## Request", + result.split_plan["request"]["title"], + "", + "## Planner Mode", + result.mode, + "", + "## Candidate Target and Actual Count", + f"target={result.candidate_target} actual={result.candidate_count}", + "", + "## Max Parallel Agents", + str(result.max_parallel_agents), + "", + "## Lane Distribution", + *[f"- {lane}: {count}" for lane, count in sorted(lane_counts.items())], + "", + "## Risk Distribution", + *[f"- {risk}: {count}" for risk, count in sorted(risk_counts.items())], + "", + "## Human Handoff Tasks", + *([f"- {task_id}" for task_id in human] or ["- None"]), + "", + "## Path Ownership Summary", + f"{sum(len(task['owned_paths']) for task in result.split_plan['tasks'])} owned path assignments, validated for non-overlap.", + "", + "## Dependencies", + f"{len([edge for edge in result.task_graph['edges'] if edge['type'] == 'depends_on'])} depends_on edges.", + "", + "## Validation Commands", + "- Planner validation checks split-plan, task-graph, non-overlap, and path safety.", + "", + "## Artifacts", + *[f"- `{item}`" for item in result.artifacts], + "", + "## Warnings", + *([f"- {item}" for item in result.warnings] or ["- None"]), + "", + ] + return "\n".join(lines) + + +def _start_here(result: PlannerResult, timestamp: str) -> str: + return "\n".join( + [ + _metadata_comment("start-here", result.run_id, timestamp), + f"# Patch Swarm Planner Run: {result.run_id}", + "", + "## What This Is", + "A durable split-plan and task-graph bundle for Patch Swarm planning. It is not live dispatch or patch application.", + "", + "## Artifact Index", + "- `request.md`", + "- `split-plan.json`", + "- `task-graph.json`", + "- `task-contracts/`", + "- `planner-report.md`", + "- `proreq/`", + "", + "## Validation Result", + "Planner artifacts were validated locally before write completion.", + "", + "## Next Operator Action", + "Review task contracts, then route later work through Patch Swarm, Factory, Workset, or Build surfaces.", + "", + ] + ) + + +def _proreq_prompt(result: PlannerResult) -> str: + return "\n".join( + [ + f"# ChatGPT Pro Patch Swarm Planner Request: {result.run_id}", + "", + "Produce a Cento Patch Swarm split plan that matches `split-plan.json` and `task-graph.json`.", + "Use coarse product lanes before microtasks. Do not exceed 100 candidates.", + "Avoid overlapping owned paths. Reject absolute paths, `..`, `.env.mcp`, and secret-like paths.", + "Mark subjective, device-bound, credential-bound, production-operation, or unsafe tasks as `human_handoff: true`.", + "", + "Required task lanes: builder, validator, docs-evidence, coordinator, integrator, human-handoff.", + "Required risk tiers: low, medium, high, human.", + "Return JSON only for the plan if the operator asks for manual import.", + "", + "## Request", + result.split_plan["request"]["summary"], + "", + ] + ) + + +def _write_proreq_artifacts(result: PlannerResult) -> list[str]: + proreq_dir = result.run_dir / "proreq" + proreq_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "artifact_type": "proreq-planning-manifest", + "candidate_target": result.candidate_target, + "created_at": result.split_plan["created_at"], + "evidence_pointers": [], + "expected_output_schema": { + "split_plan": "split-plan.json", + "task_graph": "task-graph.json", + }, + "live_pro_called": False, + "max_parallel_agents": result.max_parallel_agents, + "prompt_path": "proreq/chatgpt-pro-planner-prompt.md", + "provenance": _provenance("patch-swarm split --mode proreq", result.mode, "local"), + "request_file": result.split_plan["request"]["request_file"], + "run_id": result.run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + } + manifest_path = proreq_dir / "planning-manifest.json" + prompt_path = proreq_dir / "chatgpt-pro-planner-prompt.md" + instructions_path = proreq_dir / "manual-import-instructions.md" + write_json(manifest_path, manifest) + prompt_path.write_text(_proreq_prompt(result), encoding="utf-8") + instructions_path.write_text( + "\n".join( + [ + "# Manual Import Instructions", + "", + "Ask ChatGPT Pro for a split plan JSON object, save it locally, then run:", + "", + "```bash", + "cento parallel-delivery patch-swarm split --mode manual-import --import-plan PLAN.json --run-dir workspace/runs/parallel-delivery/imported-plan --json", + "```", + "", + "The import validator rejects unknown dependencies, overlapping paths, unsafe paths, and missing contracts.", + "", + ] + ), + encoding="utf-8", + ) + return [rel(manifest_path), rel(prompt_path), rel(instructions_path)] + + +def write_planner_artifacts(result: PlannerResult) -> PlannerResult: + """Write split-plan.json, task-graph.json, task contracts, planner report, and start-here.""" + result.run_dir.mkdir(parents=True, exist_ok=True) + timestamp = result.split_plan["created_at"] + artifacts: list[str] = [] + request = PlannerRequest( + candidate_target=result.candidate_target, + max_parallel_agents=result.max_parallel_agents, + mode=result.mode, + request_file=result.split_plan["request"]["request_file"], + request_text=result.split_plan["request"]["summary"], + run_dir=result.run_dir, + run_id=result.run_id, + timestamp=timestamp, + ) + artifacts.append(_write_request_artifact(result.run_dir, request, timestamp)) + split_path = result.run_dir / "split-plan.json" + graph_path = result.run_dir / "task-graph.json" + write_json(split_path, result.split_plan) + write_json(graph_path, result.task_graph) + artifacts.extend([rel(split_path), rel(graph_path)]) + artifacts.extend(write_task_contracts(result.run_dir, result.split_plan["tasks"])) + proreq_artifacts = _write_proreq_artifacts(result) + artifacts.extend(proreq_artifacts) + report_result = PlannerResult( + artifacts=artifacts, + candidate_count=result.candidate_count, + candidate_target=result.candidate_target, + errors=result.errors, + max_parallel_agents=result.max_parallel_agents, + mode=result.mode, + run_dir=result.run_dir, + run_id=result.run_id, + split_plan=result.split_plan, + task_graph=result.task_graph, + warnings=result.warnings, + ) + report_path = result.run_dir / "planner-report.md" + start_path = result.run_dir / "start-here.md" + report_path.write_text(_planner_report(report_result), encoding="utf-8") + start_path.write_text(_start_here(report_result, timestamp), encoding="utf-8") + artifacts.extend([rel(report_path), rel(start_path)]) + return PlannerResult( + artifacts=artifacts, + candidate_count=result.candidate_count, + candidate_target=result.candidate_target, + errors=result.errors, + max_parallel_agents=result.max_parallel_agents, + mode=result.mode, + run_dir=result.run_dir, + run_id=result.run_id, + split_plan=result.split_plan, + task_graph=result.task_graph, + warnings=result.warnings, + ) + + +def run_planner(request: PlannerRequest) -> PlannerResult: + """Dispatch by planner mode.""" + validate_candidate_target(request.candidate_target) + validate_max_parallel_agents(request.max_parallel_agents, request.candidate_target) + if request.mode == "fixture": + return plan_fixture(request) + if request.mode == "no-model": + return plan_no_model(request) + if request.mode == "proreq": + return plan_proreq(request) + if request.mode == "manual-import": + return plan_manual_import(request) + raise PlannerValidationError(f"unknown planner mode: {request.mode}") + + +def _response(result: PlannerResult, command: str, dry_run: bool, live_pro: bool) -> dict[str, Any]: + return { + "artifacts": result.artifacts, + "candidate_count": result.candidate_count, + "candidate_target": result.candidate_target, + "command": command, + "dry_run": dry_run, + "errors": result.errors, + "live_pro": live_pro, + "max_parallel_agents": result.max_parallel_agents, + "ok": not result.errors, + "planner_mode": result.mode, + "run_dir": rel(result.run_dir), + "run_id": result.run_id, + "state": "split_plan_created" if not result.errors else "split_plan_failed", + "warnings": result.warnings, + } + + +def _error_response( + *, + command: str, + dry_run: bool, + live_pro: bool, + mode: str, + candidate_target: int, + max_parallel_agents: int, + run_dir: Path, + run_id: str, + error: str, +) -> dict[str, Any]: + return { + "artifacts": [], + "candidate_count": 0, + "candidate_target": candidate_target, + "command": command, + "dry_run": dry_run, + "errors": [error], + "live_pro": live_pro, + "max_parallel_agents": max_parallel_agents, + "ok": False, + "planner_mode": mode, + "run_dir": rel(run_dir), + "run_id": run_id, + "state": "split_plan_failed", + "warnings": [], + } + + +def run_planner_command( + *, + command: str = "parallel-delivery patch-swarm split", + request_file: str | Path | None = None, + request_text: str | None = None, + run_id: str | None = None, + run_dir: str | Path | None = None, + mode: str = "fixture", + candidate_target: int = 100, + max_parallel_agents: int = 5, + import_plan: str | Path | None = None, + dry_run: bool = False, + live_pro: bool = False, + timestamp: str | None = None, +) -> tuple[dict[str, Any], int]: + resolved_run_id = run_id or f"planner-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" + resolved_run_dir = Path(run_dir) if run_dir else RUNS_ROOT / resolved_run_id + if not resolved_run_dir.is_absolute(): + resolved_run_dir = ROOT / resolved_run_dir + try: + mode = mode or "fixture" + if mode not in PLANNER_MODES: + raise PlannerValidationError(f"planner mode must be one of {', '.join(sorted(PLANNER_MODES))}") + validate_candidate_target(int(candidate_target)) + validate_max_parallel_agents(int(max_parallel_agents), int(candidate_target)) + text = request_text or "" + if mode in {"no-model", "proreq"}: + text = read_request_text(Path(request_file) if request_file else None, text or None) + elif mode == "fixture": + text = text or ( + "Build a local-first Patch Swarm planner fixture with bounded candidate tasks, " + "safe path ownership, task contracts, and deterministic task graph artifacts." + ) + if request_file: + text = read_request_text(Path(request_file), text) + elif mode == "manual-import": + text = text or "Manual-import Patch Swarm split plan." + if request_file: + text = read_request_text(Path(request_file), text) + request = PlannerRequest( + candidate_target=int(candidate_target), + command=command, + dry_run=dry_run, + import_plan=Path(import_plan) if import_plan else None, + live_pro=live_pro, + max_parallel_agents=int(max_parallel_agents), + mode=mode, + request_file=str(request_file) if request_file else None, + request_text=text, + run_dir=resolved_run_dir, + run_id=resolved_run_id, + timestamp=timestamp, + ) + result = run_planner(request) + return _response(result, command, dry_run, live_pro), 0 + except PlannerValidationError as exc: + return ( + _error_response( + candidate_target=int(candidate_target) if str(candidate_target).isdigit() else 0, + command=command, + dry_run=dry_run, + error=str(exc), + live_pro=live_pro, + max_parallel_agents=int(max_parallel_agents) if str(max_parallel_agents).isdigit() else 0, + mode=mode or "fixture", + run_dir=resolved_run_dir, + run_id=resolved_run_id, + ), + 2, + ) + + +def run_from_args(args: argparse.Namespace, *, command: str = "parallel-delivery patch-swarm split") -> tuple[dict[str, Any], int]: + mode = normalize_mode(args) + candidate_target = int(getattr(args, "candidate_target", 100) or getattr(args, "max_tasks", 100) or 100) + max_parallel_agents = int(getattr(args, "max_parallel_agents", 5) or 5) + return run_planner_command( + candidate_target=candidate_target, + command=command, + dry_run=bool(getattr(args, "dry_run", False)), + import_plan=getattr(args, "import_plan", "") or None, + live_pro=bool(getattr(args, "live_pro", False)), + max_parallel_agents=max_parallel_agents, + mode=mode, + request_file=getattr(args, "request_file", "") or None, + request_text=getattr(args, "request_text", "") or None, + run_dir=getattr(args, "run_dir", "") or None, + run_id=getattr(args, "run_id", "") or None, + timestamp=getattr(args, "fixed_timestamp", "") or None, + ) + + +def add_split_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--request-file", default="") + parser.add_argument("--request-text", default="", help=argparse.SUPPRESS) + parser.add_argument("--candidate-target", type=int, default=100) + parser.add_argument("--max-tasks", dest="candidate_target", type=int, help=argparse.SUPPRESS) + parser.add_argument("--max-parallel-agents", type=int, default=5) + parser.add_argument("--mode", choices=sorted(PLANNER_MODES), default="") + parser.add_argument("--fixture", action="store_true", help="Generate deterministic fixture tasks.") + parser.add_argument("--no-model", action="store_true", help="Use the deterministic rule-based splitter.") + parser.add_argument("--proreq", action="store_true", help="Emit ChatGPT Pro planner prompt artifacts.") + parser.add_argument("--manual-import", action="store_true", help="Normalize and validate an imported split plan.") + parser.add_argument("--import-plan", default="") + parser.add_argument("--run-id", default="") + parser.add_argument("--run-dir", default="") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--live-pro", action="store_true") + parser.add_argument("--fixed-timestamp", default="", help=argparse.SUPPRESS) + parser.add_argument("--json", action="store_true") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create Patch Swarm split-plan and task-graph artifacts.") + sub = parser.add_subparsers(dest="command", required=True) + split = sub.add_parser("split", help="Write planner artifacts for a Patch Swarm request.") + add_split_args(split) + split.set_defaults(func=command_split) + return parser + + +def command_split(args: argparse.Namespace) -> int: + payload, code = run_from_args(args, command="parallel-delivery patch-swarm split") + if args.json: + print(stable_json_dumps(payload), end="") + elif payload["ok"]: + print(f"{payload['state']} {payload['candidate_count']} tasks {payload['run_dir']}") + else: + print("; ".join(payload["errors"]), file=sys.stderr) + return code + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_prompts.py b/scripts/parallel_delivery_prompts.py new file mode 100644 index 0000000..de7a827 --- /dev/null +++ b/scripts/parallel_delivery_prompts.py @@ -0,0 +1,1397 @@ +#!/usr/bin/env python3 +"""Patch Swarm ChatGPT Pro prompt bundle generator. + +This helper turns Patch Swarm planning artifacts into local Markdown prompts. +It does not call ChatGPT Pro, OpenAI APIs, Codex, MCP, Taskstream, Redmine, or +worker pools. The operator copy/paste flow is the product boundary. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +try: + import parallel_delivery_artifacts as artifact_schema +except ImportError: # pragma: no cover - fallback for unusual cwd + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import parallel_delivery_artifacts as artifact_schema + + +ROOT = Path(__file__).resolve().parents[1] +RUNS_ROOT = ROOT / "workspace" / "runs" / "parallel-delivery" +DEFAULT_RUN_DIR = RUNS_ROOT / "proreq-fixture" +DEFAULT_TEMP_ROOT = ROOT / "workspace" / "runs" / "temp" / "chatgpt-pro" + +CURRENT_SCHEMA_VERSION = 1 +DEFAULT_PROMPT_COUNT = 20 +MAX_PROMPT_COUNT = 20 +PRODUCER = "cento.parallel-delivery.prompts" +FIXTURE_TASK_COUNT = 20 + +PROMPT_TYPES = { + "master", + "lane", + "task-cluster", + "validation", + "integration", + "evidence", + "human-handoff", +} + +LANES = { + "all", + "builder", + "validator", + "docs-evidence", + "coordinator", + "integrator", + "human-handoff", +} + +LANE_ORDER = [ + "coordinator", + "builder", + "validator", + "docs-evidence", + "integrator", + "human-handoff", +] + +REQUIRED_PROMPT_SECTIONS = [ + "## Mission", + "## Task Scope", + "## Owned Paths", + "## Read-Only Context", + "## Acceptance Criteria", + "## Validation Plan", + "## Evidence To Write", + "## Safety Rules", + "## Codex Output Format", + "## Expected Response Shape", +] + +CODEX_OUTPUT_SCHEMA = [ + "1. CODEx_THREAD_TITLE", + "2. MISSION", + "3. DISCOVERY_COMMANDS", + "4. OWNED_PATHS_CANDIDATES", + "5. IMPLEMENTATION_PLAN", + "6. CODE_DESIGN", + "7. VALIDATION_PLAN", + "8. EVIDENCE_TO_WRITE", + "9. ACCEPTANCE_CRITERIA", + "10. RISKS_AND_GUARDS", + "11. PASTE_TO_CODEX", +] + +RUN_LEVEL_PROMPTS = [ + ("validation", "Validation Strategy Prompt", "validation-strategy"), + ("integration", "Safe Integration Review Prompt", "integration-readiness"), + ("evidence", "Docs And Evidence Package Prompt", "evidence-package"), + ("validation", "Path Lease Review Prompt", "path-lease-review"), + ("human-handoff", "Failure Handling Prompt", "failure-handling"), + ("evidence", "Operator Demo Plan Prompt", "operator-demo"), + ("validation", "Safety And Secret Review Prompt", "safety-review"), + ("integration", "Acceptance Contract Review Prompt", "acceptance-review"), + ("evidence", "Codex Handoff Packet Prompt", "codex-handoff"), +] + +SECRET_PATH_PARTS = { + ".env", + ".env.mcp", + "secret", + "secrets", + "credential", + "credentials", + "token", + "tokens", + "key", + "keys", +} + +SECRET_VALUE_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"sk-[A-Za-z0-9_-]{16,}"), "openai-like key"), + (re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), "github-like token"), + (re.compile(r"AKIA[0-9A-Z]{16}"), "aws access key"), + (re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----"), "private key"), + ( + re.compile( + r"(?i)\b(password|passwd|token|secret|api[_-]?key)\s*[:=]\s*['\"]?([A-Za-z0-9_./+=-]{8,})" + ), + "secret assignment", + ), +] + + +class PromptBundleError(Exception): + """Raised when prompt bundle generation or validation fails.""" + + +@dataclass(frozen=True) +class PromptBundleRequest: + run_id: str + run_dir: Path + count: int + lane: str + split_plan_path: Path | None = None + task_graph_path: Path | None = None + path_leases_path: Path | None = None + request_file: Path | None = None + out_dir: Path | None = None + temp_dir: Path | None = None + copy_to_temp: bool = False + fixed_timestamp: str | None = None + + +@dataclass(frozen=True) +class PromptSpec: + prompt_id: str + prompt_type: str + title: str + lane: str + task_ids: list[str] + owned_paths: list[str] + read_only_paths: list[str] + validation_commands: list[str] + evidence_requirements: list[str] + copy_order: int + slug: str + + +@dataclass(frozen=True) +class PromptBundleResult: + run_id: str + run_dir: Path + prompt_count: int + prompt_index_path: Path + prompt_bundle_path: Path + prompts: list[dict[str, Any]] + temp_bridge_path: Path | None + warnings: list[str] + errors: list[str] + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json_dumps(payload: dict[str, Any]) -> str: + """Return deterministic JSON with sorted keys, two-space indent, and trailing newline.""" + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + """Write deterministic JSON artifact.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json_dumps(payload), encoding="utf-8") + + +def sha256_text(text: str) -> str: + """Return sha256 digest for prompt index validation.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def sha256_file(path: Path) -> str: + """Return sha256 digest for file references.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def resolve_path(value: str | Path | None, *, default: Path | None = None) -> Path | None: + if value is None or str(value) == "": + return default + path = Path(value) + return path if path.is_absolute() else ROOT / path + + +def path_for_index(run_dir: Path, path: Path) -> str: + try: + return path.resolve().relative_to(run_dir.resolve()).as_posix() + except ValueError: + return rel(path) + + +def resolve_index_entry_path(run_dir: Path, value: str) -> Path: + path = Path(value) + if path.is_absolute(): + return path + run_relative = run_dir / path + if run_relative.exists(): + return run_relative + root_relative = ROOT / path + if root_relative.exists(): + return root_relative + return run_relative + + +def validate_prompt_count(count: int) -> int: + """Require 1 <= count <= 20; docs recommend 15 or 20.""" + if not 1 <= int(count) <= MAX_PROMPT_COUNT: + raise PromptBundleError("count must be between 1 and 20") + return int(count) + + +def normalize_lane(lane: str | None) -> str: + """Normalize lane filter and reject unknown lanes.""" + value = (lane or "all").strip().lower() + if value not in LANES: + raise PromptBundleError(f"lane must be one of {', '.join(sorted(LANES))}") + return value + + +def _looks_like_secret_path(path: Path) -> bool: + lowered = [part.lower() for part in path.parts] + return any(part in SECRET_PATH_PARTS or part.startswith(".env") for part in lowered) + + +def safe_read_text(path: Path, *, max_chars: int = 20000) -> str: + """Read tracked/safe artifact text with bounded size; do not read local secret files.""" + if _looks_like_secret_path(path): + raise PromptBundleError("refusing to read local secret-like path") + return path.read_text(encoding="utf-8", errors="replace")[:max_chars] + + +def redact_secret_like_text(text: str) -> tuple[str, list[str]]: + """Redact secret-like strings from request/context before prompt rendering.""" + warnings: list[str] = [] + redacted = text + for pattern, label in SECRET_VALUE_PATTERNS: + if pattern.search(redacted): + warnings.append(f"redacted {label}") + if label == "secret assignment": + redacted = pattern.sub(lambda match: f"{match.group(1)}=[REDACTED_SECRET]", redacted) + else: + redacted = pattern.sub("[REDACTED_SECRET]", redacted) + return redacted, sorted(set(warnings)) + + +def read_json_artifact(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except json.JSONDecodeError as exc: + raise PromptBundleError(f"{rel(path)} invalid JSON: {exc.msg}") from exc + if not isinstance(payload, dict): + raise PromptBundleError(f"{rel(path)} must be a JSON object") + return payload + + +def _metadata_comment(artifact_type: str, run_id: str, timestamp: str) -> str: + payload = { + "artifact_type": artifact_type, + "created_at": timestamp, + "run_id": run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + } + return f"" + + +def _common(artifact_type: str, run_id: str, timestamp: str, command: str) -> dict[str, Any]: + return { + "artifact_type": artifact_type, + "created_at": timestamp, + "evidence_pointers": [], + "provenance": { + "command": command, + "notes": [], + "producer": PRODUCER, + "repo": "cento", + "source": "fixture", + }, + "run_id": run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + } + + +def _fixture_task(index: int, run_id: str, timestamp: str) -> dict[str, Any]: + lane = LANE_ORDER[(index - 1) % len(LANE_ORDER)] + task_id = f"task-{index:04d}" + profile_by_lane = { + "builder": "python-builder", + "validator": "test-writer", + "docs-evidence": "docs-evidence-writer", + "coordinator": "factory-planner", + "integrator": "safe-integrator", + "human-handoff": "human-operator", + } + risk = "human" if lane == "human-handoff" else ("high" if lane == "integrator" else ("medium" if lane in {"builder", "coordinator"} else "low")) + artifact_base = f"workspace/runs/parallel-delivery/{run_id}" + owned = [f"{artifact_base}/task-work/{task_id}/"] + if lane == "human-handoff": + owned = [f"{artifact_base}/human-handoff/{task_id}.md"] + depends_on = [f"task-{index - 1:04d}"] if lane == "integrator" and index > 1 else [] + return { + "acceptance_contract": [ + "Required artifacts are written only under owned paths.", + "Validation commands complete or exact failure evidence is recorded.", + "No unrelated dirty work, local secret files, or external task systems are touched.", + ], + "dependencies": depends_on, + "evidence_pointers": [], + "expected_artifacts": [f"task-work/{task_id}/evidence.json", f"task-contracts/{task_id}.md"], + "human_handoff": lane == "human-handoff", + "integration_notes": ["Safe Integrator or operator review decides any later apply order."], + "lane": lane, + "owned_paths": owned, + "read_only_paths": [ + "docs/patch-swarm.md", + "docs/parallel-delivery/patch-swarm-artifacts.md", + "docs/parallel-delivery/patch-swarm-planner.md", + ], + "rejection_triggers": [ + "Touches an unowned path.", + "Drops acceptance, validation, or evidence requirements.", + "Requires live services or copies local secret values.", + ], + "risk_tier": risk, + "state": "leased", + "story": f"As a Cento operator, I need bounded {lane} work for {task_id} with copy/paste prompt evidence.", + "summary": f"Produce deterministic {lane} evidence for {task_id}.", + "task_id": task_id, + "title": f"ProReq prompt fixture {task_id} {lane}", + "validation_commands": [ + f"test -f {artifact_base}/prompts/prompt-0001-master.md", + f"python3 -m json.tool {artifact_base}/prompt-index.json >/dev/null", + ], + "worker_profile": profile_by_lane[lane], + "written_at": timestamp, + } + + +def write_fixture_inputs(run_dir: Path, *, run_id: str, timestamp: str, task_count: int = FIXTURE_TASK_COUNT) -> None: + """Write deterministic request, split-plan, task-graph, and path-leases inputs.""" + run_dir.mkdir(parents=True, exist_ok=True) + request_body = "\n".join( + [ + _metadata_comment("request", run_id, timestamp), + "# Patch Swarm ProReq Prompt Fixture", + "", + "Create a local-only ChatGPT Pro prompt bundle for Patch Swarm workers.", + "The fixture must prove prompt counts, lane filtering, validation sections, evidence, and temp mirror behavior.", + "", + ] + ) + (run_dir / "request.md").write_text(request_body, encoding="utf-8") + + tasks = [_fixture_task(index, run_id, timestamp) for index in range(1, task_count + 1)] + split_plan = { + **_common("split-plan", run_id, timestamp, "patch-swarm prompts write-fixture"), + "candidate_count": len(tasks), + "candidate_target": task_count, + "lanes": sorted(LANES - {"all"}), + "max_candidate_tasks": task_count, + "max_parallel_agents": 5, + "planner_mode": "fixture", + "planning_policy": { + "avoid_overlapping_owned_paths": True, + "coarse_lanes_first": True, + "prompt_count_is_not_task_count": True, + }, + "request": { + "request_file": "request.md", + "summary": "Create a local-only ChatGPT Pro prompt bundle for Patch Swarm workers.", + "title": "Patch Swarm ProReq Prompt Fixture", + }, + "tasks": tasks, + "updated_at": timestamp, + } + write_json(run_dir / "split-plan.json", split_plan) + + task_ids = [task["task_id"] for task in tasks] + edges = [ + {"from": dep, "reason": f"{task['task_id']} consumes {dep} output", "to": task["task_id"], "type": "depends_on"} + for task in tasks + for dep in task["dependencies"] + ] + groups = [] + for offset in range(0, len(task_ids), 5): + groups.append({"automated": True, "group_id": f"group-{len(groups) + 1:04d}", "task_ids": task_ids[offset : offset + 5]}) + task_graph = { + **_common("task-graph", run_id, timestamp, "patch-swarm prompts write-fixture"), + "edges": edges, + "max_parallel_agents": 5, + "nodes": [ + { + "human_handoff": task["human_handoff"], + "lane": task["lane"], + "owned_paths": task["owned_paths"], + "risk_tier": task["risk_tier"], + "task_id": task["task_id"], + } + for task in tasks + ], + "parallel_groups": groups, + "topological_order": task_ids, + "updated_at": timestamp, + } + write_json(run_dir / "task-graph.json", task_graph) + + leases = { + **_common("path-leases", run_id, timestamp, "patch-swarm prompts write-fixture"), + "conflicts": [], + "leases": [ + { + "created_at": timestamp, + "lease_id": f"lease-{task['task_id']}", + "owned_paths": task["owned_paths"], + "read_only_paths": task["read_only_paths"], + "state": "active", + "task_id": task["task_id"], + } + for task in tasks + ], + } + write_json(run_dir / "path-leases.json", leases) + + +def _source_path(run_dir: Path, explicit: Path | None, filename: str) -> Path: + return explicit if explicit is not None else run_dir / filename + + +def load_run_context(request: PromptBundleRequest) -> dict[str, Any]: + """Load request, split plan, task graph, path leases, and derive task context.""" + split_path = _source_path(request.run_dir, request.split_plan_path, "split-plan.json") + graph_path = _source_path(request.run_dir, request.task_graph_path, "task-graph.json") + leases_path = _source_path(request.run_dir, request.path_leases_path, "path-leases.json") + request_path = _source_path(request.run_dir, request.request_file, "request.md") + split_plan = read_json_artifact(split_path) + task_graph = read_json_artifact(graph_path) + path_leases = read_json_artifact(leases_path) + request_text = safe_read_text(request_path) if request_path.exists() else "" + request_text, redaction_warnings = redact_secret_like_text(request_text) + task_rows = [item for item in split_plan.get("tasks", []) if isinstance(item, dict)] + tasks_by_id = {str(task.get("task_id") or ""): task for task in task_rows if task.get("task_id")} + leases_by_task: dict[str, dict[str, Any]] = {} + for lease in path_leases.get("leases", []): + if isinstance(lease, dict) and lease.get("task_id"): + leases_by_task[str(lease["task_id"])] = lease + order = [str(item) for item in task_graph.get("topological_order", []) if str(item) in tasks_by_id] + for task_id in tasks_by_id: + if task_id not in order: + order.append(task_id) + return { + "run_id": request.run_id, + "run_dir": request.run_dir, + "request_text": request_text, + "redaction_warnings": redaction_warnings, + "split_plan": split_plan, + "task_graph": task_graph, + "path_leases": path_leases, + "tasks": [tasks_by_id[task_id] for task_id in order], + "tasks_by_id": tasks_by_id, + "leases_by_task": leases_by_task, + "source_paths": { + "request": request_path, + "split_plan": split_path, + "task_graph": graph_path, + "path_leases": leases_path, + }, + } + + +def _task_value(task: dict[str, Any], key: str) -> list[str]: + value = task.get(key) + if isinstance(value, list): + return [str(item) for item in value if str(item).strip()] + return [] + + +def _unique(items: list[str], *, limit: int = 80) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for item in items: + if item and item not in seen: + seen.add(item) + result.append(item) + if len(result) >= limit: + break + return result + + +def _task_ids_for_lane(context: dict[str, Any], lane: str) -> list[str]: + return [ + str(task.get("task_id")) + for task in context["tasks"] + if str(task.get("task_id") or "") and (lane == "all" or str(task.get("lane") or "") == lane) + ] + + +def _paths_for_tasks(context: dict[str, Any], task_ids: list[str], field: str) -> list[str]: + tasks_by_id = context["tasks_by_id"] + leases_by_task = context["leases_by_task"] + values: list[str] = [] + for task_id in task_ids: + lease = leases_by_task.get(task_id, {}) + values.extend([str(item) for item in lease.get(field, [])] if isinstance(lease.get(field), list) else []) + values.extend(_task_value(tasks_by_id.get(task_id, {}), field)) + return _unique(values, limit=60) + + +def _validation_for_tasks(context: dict[str, Any], task_ids: list[str]) -> list[str]: + tasks_by_id = context["tasks_by_id"] + commands: list[str] = [] + for task_id in task_ids: + commands.extend(_task_value(tasks_by_id.get(task_id, {}), "validation_commands")) + if not commands: + commands = [ + "python3 scripts/parallel_delivery_prompts.py validate-bundle --run-dir workspace/runs/parallel-delivery/proreq-fixture --json", + "python3 -m json.tool workspace/runs/parallel-delivery/proreq-fixture/prompt-index.json >/dev/null", + ] + return _unique(commands, limit=20) + + +def _evidence_for_tasks(context: dict[str, Any], task_ids: list[str]) -> list[str]: + tasks_by_id = context["tasks_by_id"] + evidence: list[str] = [] + for task_id in task_ids: + evidence.extend(_task_value(tasks_by_id.get(task_id, {}), "expected_artifacts")) + evidence.extend(["prompt-validation.json", "prompt-validation-report.md", "summary.md"]) + return _unique(evidence, limit=30) + + +def _acceptance_for_tasks(context: dict[str, Any], task_ids: list[str]) -> list[str]: + tasks_by_id = context["tasks_by_id"] + acceptance: list[str] = [] + for task_id in task_ids: + acceptance.extend(_task_value(tasks_by_id.get(task_id, {}), "acceptance_contract")) + if not acceptance: + acceptance = [ + "Generated Codex packet preserves owned path boundaries.", + "Validation and evidence requirements are explicit.", + "No live services or secret material are required.", + ] + return _unique(acceptance, limit=30) + + +def _spec( + context: dict[str, Any], + *, + copy_order: int, + prompt_type: str, + title: str, + lane: str, + task_ids: list[str], + slug: str, +) -> PromptSpec: + if prompt_type not in PROMPT_TYPES: + raise PromptBundleError(f"unknown prompt_type: {prompt_type}") + if lane != "all" and lane not in LANES: + raise PromptBundleError(f"unknown lane: {lane}") + task_ids = _unique(task_ids, limit=80) + if not task_ids: + task_ids = _task_ids_for_lane(context, lane if lane != "all" else "all") + owned_paths = _paths_for_tasks(context, task_ids, "owned_paths") + read_only_paths = _paths_for_tasks(context, task_ids, "read_only_paths") + if not owned_paths: + owned_paths = [f"workspace/runs/parallel-delivery/{context['run_id']}/prompt-work/{slug}/"] + if not read_only_paths: + read_only_paths = ["docs/patch-swarm.md", "scripts/parallel_delivery.py", "data/tools.json"] + return PromptSpec( + copy_order=copy_order, + evidence_requirements=_evidence_for_tasks(context, task_ids), + lane=lane, + owned_paths=owned_paths, + prompt_id=f"prompt-{copy_order:04d}", + prompt_type=prompt_type, + read_only_paths=read_only_paths, + slug=slug, + task_ids=task_ids, + title=title, + validation_commands=_validation_for_tasks(context, task_ids), + ) + + +def build_prompt_specs(context: dict[str, Any], *, count: int, lane: str) -> list[PromptSpec]: + """Create deterministic master/lane/task-cluster prompt specs.""" + count = validate_prompt_count(count) + lane = normalize_lane(lane) + specs: list[PromptSpec] = [] + all_task_ids = _task_ids_for_lane(context, "all") + filtered_task_ids = all_task_ids if lane == "all" else _task_ids_for_lane(context, lane) + + specs.append( + _spec( + context, + copy_order=1, + prompt_type="master", + title="Master Patch Swarm Implementation Prompt", + lane="all", + task_ids=all_task_ids, + slug="master", + ) + ) + if count == 1: + return specs + + lanes = LANE_ORDER if lane == "all" else [lane] + for lane_name in lanes: + if len(specs) >= count - 1: + break + lane_task_ids = _task_ids_for_lane(context, lane_name) + specs.append( + _spec( + context, + copy_order=len(specs) + 1, + prompt_type="human-handoff" if lane_name == "human-handoff" else "lane", + title=f"{lane_name.title().replace('-', ' ')} Lane Prompt", + lane=lane_name, + task_ids=lane_task_ids, + slug=f"lane-{lane_name}", + ) + ) + + task_index = 0 + while len(specs) < count - 1 and task_index < len(filtered_task_ids): + group = filtered_task_ids[task_index : task_index + 2] + task_index += 2 + specs.append( + _spec( + context, + copy_order=len(specs) + 1, + prompt_type="task-cluster", + title=f"Task Cluster Prompt {' '.join(group)}", + lane=lane if lane != "all" else "all", + task_ids=group, + slug=f"task-cluster-{len(specs) + 1:04d}", + ) + ) + + run_level_index = 0 + while len(specs) < count - 1: + prompt_type, title, slug = RUN_LEVEL_PROMPTS[run_level_index % len(RUN_LEVEL_PROMPTS)] + run_level_index += 1 + specs.append( + _spec( + context, + copy_order=len(specs) + 1, + prompt_type=prompt_type, + title=title, + lane=lane if lane != "all" else "all", + task_ids=filtered_task_ids or all_task_ids, + slug=slug, + ) + ) + + specs.append( + _spec( + context, + copy_order=count, + prompt_type="evidence", + title="Final Evidence And Codex Handoff Prompt", + lane=lane if lane != "all" else "all", + task_ids=filtered_task_ids or all_task_ids, + slug="evidence", + ) + ) + return specs[:count] + + +def codex_output_schema_text() -> str: + """Return required Codex implementation packet schema text.""" + return "\n".join(CODEX_OUTPUT_SCHEMA) + + +def safety_rules_text() -> str: + """Return prompt safety rules without copying local secrets.""" + rules = [ + "Do not ask clarifying questions.", + "Make reversible assumptions.", + "Do not tell Codex to edit files before running discovery.", + "Do not tell Codex to mark Done unless validation passes.", + "If a target file is dirty, preserve unrelated hunks and make minimal additive changes.", + "Prefer small composable Python/shell tools over large framework rewrites.", + "Do not read or copy local secret files.", + "Do not include environment variables, tokens, keys, credentials, or local secret values.", + "Do not include raw command output that may contain secrets.", + "Do not include untracked file contents or broad repo dumps.", + "Do not call OpenAI, ChatGPT Pro, Codex, MCP, Taskstream, Redmine, or live worker systems.", + "Do not instruct Codex to mutate Taskstream, Redmine, or story state through direct database writes.", + "Do not instruct Codex to reset, checkout, clean, stash, or overwrite unrelated work.", + ] + return "\n".join(f"- {rule}" for rule in rules) + + +def _bullet(items: list[str], fallback: str = "None.") -> list[str]: + return [f"- `{item}`" for item in items] if items else [f"- {fallback}"] + + +def _plain_bullet(items: list[str], fallback: str = "None.") -> list[str]: + return [f"- {item}" for item in items] if items else [f"- {fallback}"] + + +def _request_excerpt(context: dict[str, Any]) -> str: + text = str(context.get("request_text") or "").strip() + if not text: + return "No request text was available beyond structured split-plan artifacts." + compact = "\n".join(line.rstrip() for line in text.splitlines()[:40]).strip() + return compact[:4000] if compact else "No request text was available beyond structured split-plan artifacts." + + +def render_prompt(context: dict[str, Any], spec: PromptSpec) -> str: + """Render a Patch Swarm prompt.""" + artifact = { + "artifact_type": "chatgpt-pro-prompt", + "prompt_id": spec.prompt_id, + "run_id": context["run_id"], + "schema_version": CURRENT_SCHEMA_VERSION, + } + acceptance = _acceptance_for_tasks(context, spec.task_ids) + dependencies = [] + for task_id in spec.task_ids: + task = context["tasks_by_id"].get(task_id, {}) + dependencies.extend(_task_value(task, "dependencies")) + lines = [ + "# Patch Swarm Prompt", + "", + f"", + "", + "You are producing a paste-ready Codex implementation packet. You are not editing the repo directly.", + "Do not ask Codex to call live AI services, mutate external task systems, copy secrets, or overwrite dirty work.", + "", + "## Mission", + "", + f"Create a high-quality Codex implementation packet for `{spec.title}` in Patch Swarm run `{context['run_id']}`.", + "The packet must be local-first, discovery-first, reversible, validation-oriented, and safe to paste into Codex.", + "", + "## Run Context", + "", + f"- Run ID: `{context['run_id']}`", + f"- Prompt ID: `{spec.prompt_id}`", + f"- Prompt type: `{spec.prompt_type}`", + f"- Lane: `{spec.lane}`", + "- Recommended model: `ChatGPT Pro`", + "- Operator action: paste this prompt into ChatGPT Pro, then paste the returned implementation packet into Codex.", + "", + "Request excerpt:", + "", + "```text", + _request_excerpt(context), + "```", + "", + "## Task Scope", + "", + f"- Title: {spec.title}", + f"- Task IDs: {', '.join(spec.task_ids) if spec.task_ids else 'run-level prompt'}", + f"- Copy order: {spec.copy_order}", + f"- Dependencies: {', '.join(_unique(dependencies, limit=20)) if dependencies else 'none'}", + "", + "## Owned Paths", + "", + *_bullet(spec.owned_paths), + "", + "## Read-Only Context", + "", + *_bullet(spec.read_only_paths), + "", + "## Acceptance Criteria", + "", + *_plain_bullet(acceptance), + "- The generated Codex packet includes explicit discovery commands before any file edits.", + "- The generated Codex packet preserves unrelated dirty work and avoids destructive git commands.", + "- The generated Codex packet includes validation and evidence closeout requirements.", + "", + "## Validation Plan", + "", + *_bullet(spec.validation_commands), + "- Codex must record exact failures and next actions if a validation command cannot pass.", + "", + "## Evidence To Write", + "", + *_bullet(spec.evidence_requirements), + "- Include durable evidence under the run directory or a task-owned evidence path.", + "", + "## Safety Rules", + "", + safety_rules_text(), + "", + "## Codex Output Format", + "", + "Return a paste-ready Codex implementation packet with exactly these top-level sections:", + "", + "```text", + codex_output_schema_text(), + "```", + "", + "The packet must also include these instructions for Codex:", + "", + "- Do not ask clarifying questions.", + "- Make reversible assumptions.", + "- Do not tell Codex to edit files before running discovery.", + "- Do not tell Codex to mark Done unless validation passes.", + "- If a target file is dirty, preserve unrelated hunks and make minimal additive changes.", + "- Prefer small composable Python/shell tools over large framework rewrites.", + "", + "## Expected Response Shape", + "", + "Respond with only the Codex implementation packet. Do not include prefaces, explanations, or alternative formats.", + "The packet should be complete enough for Codex to discover, implement, validate, and write evidence without live service calls.", + "", + "## Failure Handling", + "", + "If the implementation cannot be completed safely, instruct Codex to stop after discovery or validation and write the exact blocker, affected files, and next action.", + "", + "## Paste-To-Codex Instructions", + "", + "After ChatGPT Pro returns the packet, paste it into Codex from the Cento repo root and let Codex run discovery before edits.", + "", + ] + return "\n".join(lines) + + +def _prompt_filename(spec: PromptSpec) -> str: + suffix = spec.slug + if spec.prompt_type == "master": + suffix = "master" + elif spec.prompt_type == "evidence" and spec.slug == "evidence": + suffix = "evidence" + return f"{spec.prompt_id}-{suffix}.md" + + +def write_prompt_index_md(path: Path, bundle: dict[str, Any]) -> None: + """Write human-readable prompt index.""" + prompts = bundle.get("prompts") if isinstance(bundle.get("prompts"), list) else [] + lines = [ + "# Patch Swarm ChatGPT Pro Prompt Index", + "", + "## How to Use", + "", + "Open prompts in copy order. Paste each Markdown prompt into ChatGPT Pro, then paste the returned Codex packet into Codex for implementation or review. This bundle does not call live AI services.", + "", + "## Prompt Order", + "", + ] + for item in prompts: + lines.append(f"{item.get('copy_order')}. `{item.get('path')}` - {item.get('title')} ({item.get('prompt_type')}, lane `{item.get('lane')}`)") + lines.extend( + [ + "", + "## Master Prompt", + "", + "- Start with `prompt-0001-master.md` for the overall implementation packet.", + "", + "## Lane Prompts", + "", + "- Use lane and task-cluster prompts for focused worker packets.", + "", + "## Temp Bridge", + "", + f"- Temp bridge: `{bundle.get('temp_bridge') or 'not requested'}`", + "", + "## Evidence", + "", + "- Generated prompts, indexes, validation report, and temp bridge metadata are local run artifacts.", + "", + ] + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def _write_start_here(run_dir: Path, bundle: dict[str, Any]) -> None: + lines = [ + "# Patch Swarm ProReq Prompt Bundle", + "", + "## What This Is", + "", + "A local-only ChatGPT Pro prompt bundle for producing paste-ready Codex implementation packets.", + "", + "## Prompt Index", + "", + "- `prompt-index.md`", + "- `prompt-index.json`", + "- `prompt-bundle.json`", + "", + "## First Prompt", + "", + "- `prompts/prompt-0001-master.md`", + "", + "## Validation", + "", + "- `prompt-validation.json`", + "- `prompt-validation-report.md`", + "", + "## Temp Bridge", + "", + f"- `{bundle.get('temp_bridge') or 'not requested'}`", + "", + ] + (run_dir / "start-here.md").write_text("\n".join(lines), encoding="utf-8") + + +def write_temp_bridge(request: PromptBundleRequest, bundle: dict[str, Any]) -> dict[str, Any]: + """Write temp mirror/current prompt and return temp bridge metadata.""" + run_dir = request.run_dir + temp_dir = request.temp_dir or DEFAULT_TEMP_ROOT / request.run_id + prompts = bundle.get("prompts") if isinstance(bundle.get("prompts"), list) else [] + temp_prompt_dir = temp_dir / "prompts" + temp_prompt_dir.mkdir(parents=True, exist_ok=True) + for old_prompt in temp_prompt_dir.glob("prompt-*.md"): + old_prompt.unlink() + for item in prompts: + source = resolve_index_entry_path(run_dir, str(item.get("path") or "")) + if source.exists(): + target = temp_prompt_dir / source.name + target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + index_source = run_dir / "prompt-index.md" + if index_source.exists(): + (temp_dir / "prompt-index.md").write_text(index_source.read_text(encoding="utf-8"), encoding="utf-8") + first_prompt = resolve_index_entry_path(run_dir, str(prompts[0].get("path") or "")) if prompts and isinstance(prompts[0], dict) else run_dir / "prompts" / "prompt-0001-master.md" + current = temp_dir / "current.md" + if first_prompt.exists(): + text = first_prompt.read_text(encoding="utf-8") + current.write_text(text, encoding="utf-8") + (run_dir / "temp-current-prompt.md").write_text(text, encoding="utf-8") + bridge = { + "artifact_type": "temp-bridge", + "cento_temp_command": "cento temp run", + "cento_temp_supported": False, + "created_at": request.fixed_timestamp or utc_now(), + "current_prompt": rel(current), + "notes": [ + "`cento temp run` is a fixed pbcopy wrapper and does not read generated temp command JSON.", + "To copy this generated prompt through the temp bridge, edit `COPY_FILE` in scripts/cento_temp.sh to the current_prompt path.", + "Prompt generation did not copy to the OS clipboard.", + ], + "run_id": request.run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + "source_prompt": "prompts/prompt-0001-master.md", + "temp_command": "", + "temp_dir": rel(temp_dir), + } + write_json(run_dir / "temp-bridge.json", bridge) + return bridge + + +def _validation_report_md(validation: dict[str, Any]) -> str: + lines = [ + "# Patch Swarm Prompt Validation Report", + "", + "## Summary", + "", + f"- Status: `{'passed' if validation.get('ok') else 'failed'}`", + f"- Run ID: `{validation.get('run_id')}`", + f"- Prompt count: `{validation.get('prompt_count')}`", + "", + "## Errors", + "", + *([f"- {item}" for item in validation.get("errors", [])] or ["- None"]), + "", + "## Warnings", + "", + *([f"- {item}" for item in validation.get("warnings", [])] or ["- None"]), + "", + "## Checked Prompts", + "", + ] + for item in validation.get("checked_prompts", []): + lines.append(f"- `{item.get('path')}`") + lines.append("") + return "\n".join(lines) + + +def write_prompt_bundle(request: PromptBundleRequest) -> PromptBundleResult: + """Write prompt Markdown files, prompt index, bundle metadata, reports, and optional temp bridge.""" + count = validate_prompt_count(request.count) + lane = normalize_lane(request.lane) + run_dir = request.run_dir + out_dir = request.out_dir or run_dir / "prompts" + run_dir.mkdir(parents=True, exist_ok=True) + out_dir.mkdir(parents=True, exist_ok=True) + for old_prompt in out_dir.glob("prompt-*.md"): + old_prompt.unlink() + context = load_run_context(request) + timestamp = request.fixed_timestamp or utc_now() + specs = build_prompt_specs(context, count=count, lane=lane) + prompt_entries: list[dict[str, Any]] = [] + warnings = list(context.get("redaction_warnings") or []) + for spec in specs: + text = render_prompt(context, spec) + path = out_dir / _prompt_filename(spec) + path.write_text(text, encoding="utf-8") + prompt_entries.append( + { + "copy_order": spec.copy_order, + "evidence_requirements": spec.evidence_requirements, + "lane": spec.lane, + "operator_action": "paste_into_chatgpt_pro_then_paste_result_to_codex", + "owned_paths": spec.owned_paths, + "path": path_for_index(run_dir, path), + "prompt_id": spec.prompt_id, + "prompt_type": spec.prompt_type, + "read_only_paths": spec.read_only_paths, + "recommended_model": "ChatGPT Pro", + "sha256": sha256_text(text), + "task_ids": spec.task_ids, + "title": spec.title, + "validation_commands": spec.validation_commands, + } + ) + + index = { + "artifact_type": "prompt-index", + "created_at": timestamp, + "prompts": prompt_entries, + "prompt_count": len(prompt_entries), + "run_id": request.run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + "updated_at": timestamp, + } + write_json(run_dir / "prompt-index.json", index) + + bundle = { + "artifact_type": "prompt-bundle", + "created_at": timestamp, + "evidence_pointers": [], + "lane_filter": None if lane == "all" else lane, + "policy": { + "include_acceptance_contract": True, + "include_codex_output_schema": True, + "include_evidence": True, + "include_owned_paths": True, + "include_safety_rules": True, + "include_validation": True, + "no_api_calls_by_default": True, + "no_secrets": True, + "operator_copy_paste_flow": True, + }, + "prompt_count": len(prompt_entries), + "prompts": prompt_entries, + "provenance": { + "command": "patch-swarm prompts", + "notes": [], + "producer": PRODUCER, + "source": "split-plan/task-graph/path-leases", + }, + "requested_count": count, + "run_id": request.run_id, + "schema_version": CURRENT_SCHEMA_VERSION, + "source_artifacts": { + "path_leases": path_for_index(run_dir, context["source_paths"]["path_leases"]), + "request": path_for_index(run_dir, context["source_paths"]["request"]), + "split_plan": path_for_index(run_dir, context["source_paths"]["split_plan"]), + "task_graph": path_for_index(run_dir, context["source_paths"]["task_graph"]), + }, + "temp_bridge": None, + "updated_at": timestamp, + "warnings": warnings, + } + write_prompt_index_md(run_dir / "prompt-index.md", {**bundle, "prompts": prompt_entries}) + if request.copy_to_temp: + bridge = write_temp_bridge(request, {**bundle, "prompts": prompt_entries}) + bundle["temp_bridge"] = "temp-bridge.json" + bundle["evidence_pointers"].append({"path": "temp-bridge.json", "description": "Local temp prompt mirror manifest"}) + warnings.extend(bridge.get("notes", [])) + bundle["warnings"] = _unique(warnings, limit=50) + write_json(run_dir / "prompt-bundle.json", bundle) + write_prompt_index_md(run_dir / "prompt-index.md", bundle) + _write_start_here(run_dir, bundle) + validation = validate_prompt_bundle(run_dir) + write_json(run_dir / "prompt-validation.json", validation) + (run_dir / "prompt-validation-report.md").write_text(_validation_report_md(validation), encoding="utf-8") + return PromptBundleResult( + errors=list(validation.get("errors", [])), + prompt_bundle_path=run_dir / "prompt-bundle.json", + prompt_count=len(prompt_entries), + prompt_index_path=run_dir / "prompt-index.json", + prompts=prompt_entries, + run_dir=run_dir, + run_id=request.run_id, + temp_bridge_path=run_dir / "temp-bridge.json" if request.copy_to_temp else None, + warnings=list(bundle.get("warnings", [])), + ) + + +def validate_prompt_file(path: Path) -> list[str]: + """Validate required sections and safety constraints for one prompt.""" + errors: list[str] = [] + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return [f"{rel(path)} does not exist"] + if "# Patch Swarm Prompt" not in text: + errors.append(f"{rel(path)} missing # Patch Swarm Prompt") + for heading in REQUIRED_PROMPT_SECTIONS: + if heading not in text: + errors.append(f"{rel(path)} missing {heading}") + for required in ["CODEx_THREAD_TITLE", "PASTE_TO_CODEX"]: + if required not in text: + errors.append(f"{rel(path)} missing {required}") + for pattern, label in SECRET_VALUE_PATTERNS: + if pattern.search(text): + errors.append(f"{rel(path)} contains secret-like {label}") + return errors + + +def validate_prompt_bundle(run_dir: Path) -> dict[str, Any]: + """Validate prompt bundle metadata, index references, prompt sections, and hashes.""" + run_dir = resolve_path(run_dir) or run_dir + errors: list[str] = [] + warnings: list[str] = [] + checked: list[dict[str, Any]] = [] + bundle = read_json_artifact(run_dir / "prompt-bundle.json") + index = read_json_artifact(run_dir / "prompt-index.json") + prompts = index.get("prompts") if isinstance(index.get("prompts"), list) else [] + bundle_prompts = bundle.get("prompts") if isinstance(bundle.get("prompts"), list) else [] + requested = int(bundle.get("requested_count") or len(prompts)) + if bundle.get("artifact_type") != "prompt-bundle": + errors.append("prompt-bundle artifact_type must be prompt-bundle") + if index.get("artifact_type") != "prompt-index": + errors.append("prompt-index artifact_type must be prompt-index") + if len(prompts) != requested: + errors.append(f"prompt count mismatch: expected {requested}, found {len(prompts)}") + if len(bundle_prompts) != len(prompts): + errors.append("prompt-bundle prompts length must match prompt-index prompts length") + for entry in prompts: + if not isinstance(entry, dict): + errors.append("prompt-index prompts entries must be objects") + continue + prompt_path = resolve_index_entry_path(run_dir, str(entry.get("path") or "")) + prompt_errors = validate_prompt_file(prompt_path) + errors.extend(prompt_errors) + if prompt_path.exists(): + actual = sha256_file(prompt_path) + if actual != str(entry.get("sha256") or ""): + errors.append(f"{entry.get('path')} sha256 mismatch") + checked.append({"path": str(entry.get("path") or ""), "sha256": actual}) + if bundle.get("temp_bridge"): + bridge = read_json_artifact(run_dir / "temp-bridge.json") + current_prompt = resolve_path(str(bridge.get("current_prompt") or "")) + if not current_prompt or not current_prompt.exists(): + errors.append("temp bridge current_prompt does not exist") + temp_index = resolve_path(str(bridge.get("temp_dir") or "")) / "prompt-index.md" if bridge.get("temp_dir") else None + if temp_index and not temp_index.exists(): + errors.append("temp bridge prompt-index.md does not exist") + return { + "checked_prompts": checked, + "errors": errors, + "ok": not errors, + "prompt_count": len(prompts), + "run_id": str(bundle.get("run_id") or index.get("run_id") or run_dir.name), + "warnings": warnings, + } + + +def build_proreq_fixture( + run_dir: Path, + *, + run_id: str, + count: int, + timestamp: str, + lane: str = "all", + copy_to_temp: bool = False, + temp_dir: Path | None = None, +) -> PromptBundleResult: + """Generate deterministic fixture inputs and prompt bundle.""" + write_fixture_inputs(run_dir, run_id=run_id, timestamp=timestamp, task_count=FIXTURE_TASK_COUNT) + return write_prompt_bundle( + PromptBundleRequest( + copy_to_temp=copy_to_temp, + count=count, + fixed_timestamp=timestamp, + lane=lane, + run_dir=run_dir, + run_id=run_id, + temp_dir=temp_dir, + ) + ) + + +def print_policy() -> dict[str, Any]: + """Return local prompt generator policy.""" + return { + "default_count": DEFAULT_PROMPT_COUNT, + "lanes": sorted(LANES), + "max_count": MAX_PROMPT_COUNT, + "no_api_calls_by_default": True, + "no_live_ai_calls": True, + "no_secrets": True, + "operator_copy_paste_flow": True, + "prompt_types": sorted(PROMPT_TYPES), + "required_sections": REQUIRED_PROMPT_SECTIONS, + "schema_version": CURRENT_SCHEMA_VERSION, + } + + +def _result_payload(result: PromptBundleResult, *, command: str, lane: str) -> dict[str, Any]: + return { + "artifacts": [ + rel(result.prompt_bundle_path), + rel(result.prompt_index_path), + rel(result.run_dir / "prompt-index.md"), + rel(result.run_dir / "prompt-validation.json"), + rel(result.run_dir / "prompt-validation-report.md"), + ], + "command": command, + "errors": result.errors, + "lane": lane, + "ok": not result.errors, + "prompt_bundle": rel(result.prompt_bundle_path), + "prompt_count": result.prompt_count, + "prompt_index": rel(result.prompt_index_path), + "prompt_index_md": rel(result.run_dir / "prompt-index.md"), + "run_dir": rel(result.run_dir), + "run_id": result.run_id, + "state": "prompt_bundle_created" if not result.errors else "prompt_bundle_failed", + "temp_bridge": rel(result.temp_bridge_path) if result.temp_bridge_path else "", + "warnings": result.warnings, + } + + +def _request_from_args(args: argparse.Namespace, *, default_run_id: str = "proreq-fixture") -> PromptBundleRequest: + run_dir = resolve_path(getattr(args, "run_dir", "") or None, default=DEFAULT_RUN_DIR) + assert run_dir is not None + run_id = getattr(args, "run_id", "") or run_dir.name or default_run_id + temp_dir = resolve_path(getattr(args, "temp_dir", "") or None, default=DEFAULT_TEMP_ROOT / run_id) + return PromptBundleRequest( + copy_to_temp=bool(getattr(args, "copy_to_temp", False)), + count=validate_prompt_count(int(getattr(args, "count", DEFAULT_PROMPT_COUNT) or DEFAULT_PROMPT_COUNT)), + fixed_timestamp=getattr(args, "fixed_timestamp", "") or None, + lane=normalize_lane(getattr(args, "lane", "all")), + out_dir=resolve_path(getattr(args, "out_dir", "") or None), + path_leases_path=resolve_path(getattr(args, "path_leases", "") or None), + request_file=resolve_path(getattr(args, "request_file", "") or None), + run_dir=run_dir, + run_id=run_id, + split_plan_path=resolve_path(getattr(args, "split_plan", "") or None), + task_graph_path=resolve_path(getattr(args, "task_graph", "") or None), + temp_dir=temp_dir, + ) + + +def run_generate_from_args(args: argparse.Namespace, *, command: str = "parallel-delivery patch-swarm prompts") -> tuple[dict[str, Any], int]: + try: + request = _request_from_args(args) + timestamp = request.fixed_timestamp or utc_now() + missing = [name for name in ["request.md", "split-plan.json", "task-graph.json", "path-leases.json"] if not (request.run_dir / name).exists()] + if missing: + write_fixture_inputs(request.run_dir, run_id=request.run_id, timestamp=timestamp, task_count=FIXTURE_TASK_COUNT) + result = write_prompt_bundle(request) + payload = _result_payload(result, command=command, lane=request.lane) + return payload, 0 if payload["ok"] else 1 + except PromptBundleError as exc: + payload = { + "artifacts": [], + "command": command, + "errors": [str(exc)], + "lane": getattr(args, "lane", "all"), + "ok": False, + "prompt_count": 0, + "run_id": getattr(args, "run_id", "") or "unknown", + "state": "prompt_bundle_failed", + "warnings": [], + } + return payload, 2 + + +def command_write_fixture(args: argparse.Namespace) -> int: + try: + run_dir = resolve_path(args.run_dir, default=DEFAULT_RUN_DIR) + assert run_dir is not None + run_id = args.run_id or run_dir.name or "proreq-fixture" + result = build_proreq_fixture( + run_dir, + copy_to_temp=bool(args.copy_to_temp), + count=validate_prompt_count(args.count), + lane=normalize_lane(args.lane), + run_id=run_id, + temp_dir=resolve_path(args.temp_dir or None, default=DEFAULT_TEMP_ROOT / run_id), + timestamp=args.fixed_timestamp or utc_now(), + ) + payload = _result_payload(result, command="parallel-delivery patch-swarm prompts", lane=normalize_lane(args.lane)) + print(stable_json_dumps(payload) if args.json else f"{payload['state']} {payload['prompt_count']} prompts {payload['run_dir']}", end="" if args.json else "\n") + return 0 if payload["ok"] else 1 + except PromptBundleError as exc: + payload = {"ok": False, "state": "prompt_bundle_failed", "errors": [str(exc)], "warnings": []} + print(stable_json_dumps(payload) if args.json else str(exc), end="" if args.json else "\n", file=sys.stdout if args.json else sys.stderr) + return 2 + + +def command_generate(args: argparse.Namespace) -> int: + payload, code = run_generate_from_args(args) + print(stable_json_dumps(payload) if args.json else f"{payload['state']} {payload.get('prompt_count', 0)} prompts", end="" if args.json else "\n") + return code + + +def command_validate_bundle(args: argparse.Namespace) -> int: + try: + run_dir = resolve_path(args.run_dir, default=DEFAULT_RUN_DIR) + assert run_dir is not None + payload = validate_prompt_bundle(run_dir) + print(stable_json_dumps(payload) if args.json else ("passed" if payload["ok"] else "failed"), end="" if args.json else "\n") + return 0 if payload["ok"] else 1 + except PromptBundleError as exc: + payload = {"checked_prompts": [], "errors": [str(exc)], "ok": False, "prompt_count": 0, "run_id": "", "warnings": []} + print(stable_json_dumps(payload) if args.json else str(exc), end="" if args.json else "\n", file=sys.stdout if args.json else sys.stderr) + return 2 + + +def command_print_policy(args: argparse.Namespace) -> int: + payload = print_policy() + print(stable_json_dumps(payload) if args.json else json.dumps(payload, indent=2, sort_keys=True), end="" if args.json else "\n") + return 0 + + +def add_common_generation_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-dir", default=str(DEFAULT_RUN_DIR.relative_to(ROOT))) + parser.add_argument("--split-plan", default="") + parser.add_argument("--task-graph", default="") + parser.add_argument("--path-leases", default="") + parser.add_argument("--request-file", default="") + parser.add_argument("--out-dir", default="") + parser.add_argument("--temp-dir", default="") + parser.add_argument("--run-id", default="") + parser.add_argument("--count", type=int, default=DEFAULT_PROMPT_COUNT) + parser.add_argument("--lane", default="all", choices=sorted(LANES)) + parser.add_argument("--fixed-timestamp", default="") + parser.add_argument("--copy-to-temp", action="store_true") + parser.add_argument("--chatgpt-pro", action="store_true", help="Document intent for ChatGPT Pro copy/paste prompts; no live call is made.") + parser.add_argument("--json", action="store_true") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate local Patch Swarm ChatGPT Pro prompt bundles.") + sub = parser.add_subparsers(dest="command", required=True) + + fixture = sub.add_parser("write-fixture", help="Write deterministic fixture inputs and prompt bundle.") + add_common_generation_args(fixture) + fixture.set_defaults(func=command_write_fixture) + + generate = sub.add_parser("generate", help="Generate prompts from existing run artifacts.") + add_common_generation_args(generate) + generate.set_defaults(func=command_generate) + + validate = sub.add_parser("validate-bundle", help="Validate prompt bundle artifacts.") + validate.add_argument("--run-dir", default=str(DEFAULT_RUN_DIR.relative_to(ROOT))) + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_validate_bundle) + + policy = sub.add_parser("print-policy", help="Print local prompt generation policy.") + policy.add_argument("--json", action="store_true") + policy.set_defaults(func=command_print_policy) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_release_candidate.py b/scripts/parallel_delivery_release_candidate.py new file mode 100644 index 0000000..638063b --- /dev/null +++ b/scripts/parallel_delivery_release_candidate.py @@ -0,0 +1,1002 @@ +#!/usr/bin/env python3 +"""Safe apply and release-candidate artifacts for Parallel Delivery.""" + +from __future__ import annotations + +import argparse +import difflib +import hashlib +import json +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal + + +ROOT = Path(__file__).resolve().parents[1] +RUNS_ROOT = ROOT / "workspace" / "runs" + +SCHEMA_INTEGRATION_RECEIPT = "cento.parallel_delivery.integration_receipt.v1" +SCHEMA_BUNDLE_RECEIPT = "cento.parallel_delivery.bundle_receipt.v1" +SCHEMA_APPLY_STEP_RECEIPT = "cento.parallel_delivery.apply_step_receipt.v1" +SCHEMA_APPLY_REPORT = "cento.parallel_delivery.apply_report.v1" +SCHEMA_ROLLBACK_METADATA = "cento.parallel_delivery.rollback_metadata.v1" +SCHEMA_RELEASE_CANDIDATE = "cento.parallel_delivery.release_candidate.v1" + +FORBIDDEN_COMMAND_SNIPPETS = ( + "git reset", + "git checkout", + "git clean", + "git stash", + ".env.mcp", + "taskstream db", + "redmine db", +) + + +class ReleaseCandidateError(RuntimeError): + """Expected safe-apply or release-candidate failure.""" + + +@dataclass(frozen=True) +class AcceptedIntegrationReceipt: + schema: str + integration_id: str + run_id: str + base_commit: str + status: str + accepted_bundle_receipts: list[str] + rejected_bundle_receipts: list[str] + apply_order: list[str] + final_validation_commands: list[dict[str, Any]] + payload: dict[str, Any] + path: Path + + +@dataclass(frozen=True) +class BundleApplyPlan: + bundle_id: str + task_id: str + worker_id: str + receipt_path: Path + patch_path: Path + patch_sha256: str + touched_paths: list[str] + validation_commands: list[dict[str, Any]] + step_index: int + + +@dataclass(frozen=True) +class IntegrationTarget: + target_repo: Path + target_worktree: Path + base_commit: str + pre_apply_head: str + rollback_strategy: str + mode: str + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json_dumps(payload: Any) -> str: + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json_dumps(payload), encoding="utf-8") + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ReleaseCandidateError(f"file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise ReleaseCandidateError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(payload, dict): + raise ReleaseCandidateError(f"expected JSON object in {path}") + return payload + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def out_rel(path: Path, out_dir: Path) -> str: + try: + return path.resolve().relative_to(out_dir.resolve()).as_posix() + except ValueError: + return rel(path) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def resolve_existing_path(value: str, roots: list[Path], *, field: str) -> Path: + path = Path(value) + candidates = [path] if path.is_absolute() else [root / path for root in roots] + for candidate in candidates: + if candidate.exists(): + return candidate.resolve() + checked = ", ".join(str(candidate) for candidate in candidates) + raise ReleaseCandidateError(f"{field} does not exist: {value} (checked {checked})") + + +def normalize_commands(values: Any) -> list[dict[str, Any]]: + commands: list[dict[str, Any]] = [] + if values is None: + return commands + if isinstance(values, str): + values = [values] + if not isinstance(values, list): + raise ReleaseCandidateError("validation commands must be a list or string") + for item in values: + if isinstance(item, str): + cmd = item.strip() + timeout = 120 + elif isinstance(item, dict): + cmd = str(item.get("cmd") or item.get("command") or "").strip() + timeout = int(item.get("timeout_seconds") or item.get("timeout") or 120) + else: + raise ReleaseCandidateError("validation command entries must be strings or objects") + if not cmd: + continue + lowered = cmd.lower() + for forbidden in FORBIDDEN_COMMAND_SNIPPETS: + if forbidden in lowered: + raise ReleaseCandidateError(f"unsafe validation command refused: {cmd}") + commands.append({"cmd": cmd, "timeout_seconds": timeout}) + return commands + + +def load_integration_receipt(path: Path) -> AcceptedIntegrationReceipt: + path = path.resolve() + payload = read_json(path) + schema = str(payload.get("schema") or payload.get("schema_version") or "") + if schema != SCHEMA_INTEGRATION_RECEIPT: + raise ReleaseCandidateError(f"integration receipt schema mismatch: {schema or ''}") + accepted = [str(item) for item in payload.get("accepted_bundle_receipts") or []] + rejected = [str(item) for item in payload.get("rejected_bundle_receipts") or []] + apply_order = [str(item) for item in payload.get("apply_order") or []] + if not accepted: + raise ReleaseCandidateError("integration receipt has no accepted_bundle_receipts") + if not apply_order: + raise ReleaseCandidateError("integration receipt has no apply_order") + return AcceptedIntegrationReceipt( + schema=schema, + integration_id=str(payload.get("integration_id") or path.stem), + run_id=str(payload.get("run_id") or path.parent.parent.name or path.parent.name), + base_commit=str(payload.get("base_commit") or ""), + status=str(payload.get("status") or ""), + accepted_bundle_receipts=accepted, + rejected_bundle_receipts=rejected, + apply_order=apply_order, + final_validation_commands=normalize_commands(payload.get("final_validation_commands") or []), + payload=payload, + path=path, + ) + + +def assert_integration_receipt_accepted(receipt: AcceptedIntegrationReceipt) -> None: + if receipt.status != "accepted": + raise ReleaseCandidateError(f"integration receipt is not accepted: {receipt.status or ''}") + + +def receipt_bundle_id(payload: dict[str, Any]) -> str: + return str(payload.get("bundle_id") or payload.get("id") or payload.get("patch_bundle_id") or "") + + +def receipt_status(payload: dict[str, Any]) -> str: + return str(payload.get("validation_status") or payload.get("status") or "").lower() + + +def receipt_bool(payload: dict[str, Any], key: str) -> bool: + return payload.get(key) is True or str(payload.get(key)).lower() == "true" + + +def receipt_touched_paths(payload: dict[str, Any]) -> list[str]: + values = payload.get("touched_paths") or payload.get("changed_paths") or payload.get("changed_files") or [] + return [str(item) for item in values if str(item)] + + +def load_receipt_list(paths: list[str], *, root: Path) -> dict[str, tuple[Path, dict[str, Any]]]: + loaded: dict[str, tuple[Path, dict[str, Any]]] = {} + for value in paths: + path = resolve_existing_path(value, [root], field="bundle receipt") + payload = read_json(path) + bundle_id = receipt_bundle_id(payload) + if not bundle_id: + raise ReleaseCandidateError(f"bundle receipt missing bundle_id: {path}") + loaded[bundle_id] = (path, payload) + return loaded + + +def load_and_verify_bundle_receipts( + integration_receipt: AcceptedIntegrationReceipt, + *, + receipt_root: Path | None = None, + expected_base_commit: str | None = None, +) -> list[BundleApplyPlan]: + receipt_root = receipt_root or integration_receipt.path.parent + if expected_base_commit and integration_receipt.base_commit and integration_receipt.base_commit != expected_base_commit: + raise ReleaseCandidateError( + f"base commit mismatch: receipt={integration_receipt.base_commit} expected={expected_base_commit}" + ) + accepted = load_receipt_list(integration_receipt.accepted_bundle_receipts, root=receipt_root) + rejected = load_receipt_list(integration_receipt.rejected_bundle_receipts, root=receipt_root) if integration_receipt.rejected_bundle_receipts else {} + rejected_in_order = [bundle_id for bundle_id in integration_receipt.apply_order if bundle_id in rejected] + if rejected_in_order: + raise ReleaseCandidateError("apply_order includes rejected bundle(s): " + ", ".join(rejected_in_order)) + + plans: list[BundleApplyPlan] = [] + for index, bundle_id in enumerate(integration_receipt.apply_order, start=1): + if bundle_id not in accepted: + raise ReleaseCandidateError(f"apply_order bundle has no accepted receipt: {bundle_id}") + receipt_path, payload = accepted[bundle_id] + status = receipt_status(payload) + if status not in {"accepted", "passed", "validated"}: + raise ReleaseCandidateError(f"bundle receipt is not accepted: {bundle_id} status={status or ''}") + if not receipt_bool(payload, "integratable"): + raise ReleaseCandidateError(f"bundle receipt is not integratable: {bundle_id}") + bundle_base = str(payload.get("base_commit") or payload.get("base_ref") or "") + if expected_base_commit and bundle_base and bundle_base != expected_base_commit: + raise ReleaseCandidateError(f"bundle base commit mismatch for {bundle_id}: {bundle_base}") + patch_value = str(payload.get("patch_path") or payload.get("diff_path") or payload.get("patch_file") or "") + if not patch_value: + raise ReleaseCandidateError(f"bundle receipt missing patch path: {bundle_id}") + patch_path = resolve_existing_path(patch_value, [receipt_path.parent, receipt_root], field="patch path") + expected_sha = str(payload.get("patch_sha256") or payload.get("sha256") or "") + actual_sha = sha256_file(patch_path) + if not expected_sha: + raise ReleaseCandidateError(f"bundle receipt missing patch_sha256: {bundle_id}") + if expected_sha != actual_sha: + raise ReleaseCandidateError(f"patch sha256 mismatch for {bundle_id}: receipt={expected_sha} actual={actual_sha}") + validation_commands = normalize_commands(payload.get("validation_commands") or payload.get("validation") or []) + if not validation_commands: + validation_commands = [{"cmd": "test -d .", "timeout_seconds": 30}] + plans.append( + BundleApplyPlan( + bundle_id=bundle_id, + task_id=str(payload.get("task_id") or bundle_id), + worker_id=str(payload.get("worker_id") or "worker"), + receipt_path=receipt_path, + patch_path=patch_path, + patch_sha256=actual_sha, + touched_paths=receipt_touched_paths(payload), + validation_commands=validation_commands, + step_index=index, + ) + ) + return plans + + +def safe_target_path(path: Path) -> bool: + resolved = path.resolve() + allowed = [ + (ROOT / "workspace" / "runs").resolve(), + (ROOT / "workspace" / "factory-integration-worktrees").resolve(), + Path("/tmp").resolve(), + ] + return any(resolved == root or root in resolved.parents for root in allowed) + + +def target_head(path: Path, fallback: str) -> str: + if not (path / ".git").exists(): + return fallback + proc = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=path, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + return proc.stdout.strip() if proc.returncode == 0 else fallback + + +def resolve_expected_base_commit(value: str | None) -> str | None: + if not value: + return None + if value == "HEAD": + proc = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + return proc.stdout.strip() if proc.returncode == 0 else value + return value + + +def prepare_integration_target( + *, + target_repo: Path, + target_worktree: Path | None, + base_commit: str, + out_dir: Path, + mode: Literal["dry-run", "apply"], + use_factory: bool = False, +) -> IntegrationTarget: + del use_factory + target_repo = target_repo.resolve() + if not target_repo.exists() or not target_repo.is_dir(): + raise ReleaseCandidateError(f"target repo does not exist: {target_repo}") + if mode == "dry-run": + return IntegrationTarget( + target_repo=target_repo, + target_worktree=target_repo, + base_commit=base_commit, + pre_apply_head=target_head(target_repo, base_commit), + rollback_strategy="dry_run_no_changes", + mode=mode, + ) + if target_worktree is None: + raise ReleaseCandidateError("apply mode requires --target-worktree") + target_worktree = target_worktree.resolve() + if not safe_target_path(target_worktree): + raise ReleaseCandidateError(f"target worktree must be under workspace/runs, workspace/factory-integration-worktrees, or /tmp: {target_worktree}") + if target_worktree.exists() and any(target_worktree.iterdir()): + raise ReleaseCandidateError(f"target worktree already exists and is not empty: {target_worktree}") + target_worktree.parent.mkdir(parents=True, exist_ok=True) + ignore = shutil.ignore_patterns("__pycache__", ".pytest_cache", "apply", "dry-run", "rejected-receipt") + shutil.copytree(target_repo, target_worktree, ignore=ignore, dirs_exist_ok=True) + return IntegrationTarget( + target_repo=target_repo, + target_worktree=target_worktree, + base_commit=base_commit, + pre_apply_head=target_head(target_worktree, base_commit), + rollback_strategy="isolated_worktree_abandonment", + mode=mode, + ) + + +def run_capture(command: str, *, cwd: Path, out_dir: Path, log_name: str, timeout: int = 120) -> dict[str, Any]: + if command.startswith("python ") and shutil.which("python") is None and shutil.which("python3"): + command = "python3 " + command[len("python ") :] + lowered = command.lower() + for forbidden in FORBIDDEN_COMMAND_SNIPPETS: + if forbidden in lowered: + raise ReleaseCandidateError(f"unsafe command refused: {command}") + logs = out_dir / "logs" + logs.mkdir(parents=True, exist_ok=True) + stdout_path = logs / f"{log_name}.stdout" + stderr_path = logs / f"{log_name}.stderr" + started = time.perf_counter() + proc = subprocess.run( + command, + cwd=cwd, + shell=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + stdout_path.write_text(proc.stdout, encoding="utf-8") + stderr_path.write_text(proc.stderr, encoding="utf-8") + return { + "cmd": command, + "exit_code": proc.returncode, + "stdout_path": out_rel(stdout_path, out_dir), + "stderr_path": out_rel(stderr_path, out_dir), + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + } + + +def dry_run_bundle_apply(target: IntegrationTarget, bundle: BundleApplyPlan, out_dir: Path) -> dict[str, Any]: + command = f"git -C {sh_quote(target.target_worktree)} apply --check --whitespace=error-all {sh_quote(bundle.patch_path)}" + return run_capture(command, cwd=ROOT, out_dir=out_dir, log_name=f"{bundle.bundle_id}.dry-run", timeout=120) + + +def apply_bundle(target: IntegrationTarget, bundle: BundleApplyPlan, out_dir: Path) -> dict[str, Any]: + command = f"git -C {sh_quote(target.target_worktree)} apply --whitespace=nowarn {sh_quote(bundle.patch_path)}" + return run_capture(command, cwd=ROOT, out_dir=out_dir, log_name=f"{bundle.bundle_id}.apply", timeout=120) + + +def run_bundle_validation(target: IntegrationTarget, bundle: BundleApplyPlan, out_dir: Path) -> list[dict[str, Any]]: + results = [] + for index, command in enumerate(bundle.validation_commands, start=1): + results.append( + run_capture( + str(command["cmd"]), + cwd=target.target_worktree, + out_dir=out_dir, + log_name=f"{bundle.bundle_id}.validation.{index}", + timeout=int(command.get("timeout_seconds") or 120), + ) + ) + if results[-1]["exit_code"] != 0: + break + return results + + +def run_final_validation(target: IntegrationTarget, commands: list[dict[str, Any]], out_dir: Path) -> list[dict[str, Any]]: + commands = commands or [{"cmd": "test -d .", "timeout_seconds": 30}] + results = [] + for index, command in enumerate(commands, start=1): + results.append( + run_capture( + str(command["cmd"]), + cwd=target.target_worktree, + out_dir=out_dir, + log_name=f"final-validation.{index}", + timeout=int(command.get("timeout_seconds") or 120), + ) + ) + if results[-1]["exit_code"] != 0: + break + return results + + +def sh_quote(path: Path | str) -> str: + return "'" + str(path).replace("'", "'\"'\"'") + "'" + + +def step_receipt_payload( + *, + receipt: AcceptedIntegrationReceipt, + target: IntegrationTarget, + bundle: BundleApplyPlan, + mode: str, + status: str, + dry_run: dict[str, Any], + apply_result: dict[str, Any] | None, + validation: list[dict[str, Any]], + timestamp: str, +) -> dict[str, Any]: + return { + "schema": SCHEMA_APPLY_STEP_RECEIPT, + "integration_id": receipt.integration_id, + "run_id": receipt.run_id, + "bundle_id": bundle.bundle_id, + "task_id": bundle.task_id, + "worker_id": bundle.worker_id, + "step_index": bundle.step_index, + "mode": mode, + "status": status, + "base_commit": receipt.base_commit, + "target_repo": rel(target.target_repo), + "target_worktree": rel(target.target_worktree), + "patch_path": rel(bundle.patch_path), + "patch_sha256": bundle.patch_sha256, + "dry_run": dry_run, + "apply": apply_result, + "validation": validation, + "touched_paths": bundle.touched_paths, + "created_at": timestamp, + } + + +def write_step_receipt(out_dir: Path, bundle: BundleApplyPlan, payload: dict[str, Any]) -> str: + path = out_dir / "apply-receipts" / f"step-{bundle.step_index:03d}-{bundle.bundle_id}.json" + write_json(path, payload) + return out_rel(path, out_dir) + + +def write_rollback_metadata( + *, + out_dir: Path, + receipt: AcceptedIntegrationReceipt, + target: IntegrationTarget | None, + mode: str, + applied_bundle_ids: list[str], + failed_bundle_id: str | None, + failure_reason: str | None, + timestamp: str, +) -> str: + target_repo = target.target_repo if target else Path("") + target_worktree = target.target_worktree if target else Path("") + payload = { + "schema": SCHEMA_ROLLBACK_METADATA, + "integration_id": receipt.integration_id, + "run_id": receipt.run_id, + "mode": mode, + "rollback_strategy": target.rollback_strategy if target else "dry_run_no_changes", + "factory_receipt": None, + "target_repo": rel(target_repo) if target else "", + "target_worktree": rel(target_worktree) if target else "", + "pre_apply_head": target.pre_apply_head if target else receipt.base_commit, + "post_apply_head": target_head(target_worktree, receipt.base_commit) if target else receipt.base_commit, + "applied_bundle_ids": applied_bundle_ids, + "failed_bundle_id": failed_bundle_id, + "failure_reason": failure_reason, + "safe_cleanup_owner": "factory_or_operator", + "destructive_commands_used": False, + "created_at": timestamp, + } + path = out_dir / "rollback-metadata.json" + write_json(path, payload) + return out_rel(path, out_dir) + + +def write_integrated_diff(target: IntegrationTarget, out_dir: Path) -> str: + path = out_dir / "integrated.diff" + command = [ + "diff", + "-ruN", + "--exclude=.git", + "--exclude=__pycache__", + "--exclude=.pytest_cache", + str(target.target_repo), + str(target.target_worktree), + ] + proc = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + path.write_text(proc.stdout if proc.stdout else proc.stderr, encoding="utf-8") + return out_rel(path, out_dir) + + +def write_release_notes( + *, + out_dir: Path, + receipt: AcceptedIntegrationReceipt, + plans: list[BundleApplyPlan], + rejected_bundle_ids: list[str], + final_validation: list[dict[str, Any]], +) -> str: + lines = [ + "# Parallel Delivery Release Notes Draft", + "", + f"- Integration ID: `{receipt.integration_id}`", + f"- Run ID: `{receipt.run_id}`", + f"- Base commit: `{receipt.base_commit}`", + "", + "## Accepted Bundles", + ] + for plan in plans: + touched = ", ".join(plan.touched_paths) or "no touched paths recorded" + lines.append(f"- `{plan.bundle_id}` (`{plan.task_id}`): {touched}") + lines.extend(["", "## Rejected Bundles Not Included"]) + if rejected_bundle_ids: + lines.extend(f"- `{bundle_id}`" for bundle_id in rejected_bundle_ids) + else: + lines.append("- none") + lines.extend(["", "## Final Validation"]) + if final_validation: + for item in final_validation: + status = "passed" if item["exit_code"] == 0 else "failed" + lines.append(f"- `{item['cmd']}`: {status} ({item['stdout_path']}, {item['stderr_path']})") + else: + lines.append("- not run") + lines.extend(["", "## Rollback", "- Rollback is metadata-only through isolated worktree abandonment. No destructive cleanup was performed."]) + path = out_dir / "release-notes.md" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return out_rel(path, out_dir) + + +def create_release_candidate_packet( + *, + integration_receipt: AcceptedIntegrationReceipt, + apply_report: dict[str, Any], + target: IntegrationTarget, + plans: list[BundleApplyPlan], + out_dir: Path, + final_validation: list[dict[str, Any]], + timestamp: str, +) -> dict[str, Any]: + integrated_diff = write_integrated_diff(target, out_dir) + release_notes = write_release_notes( + out_dir=out_dir, + receipt=integration_receipt, + plans=plans, + rejected_bundle_ids=[receipt_bundle_id(read_json(resolve_existing_path(path, [integration_receipt.path.parent], field="rejected bundle receipt"))) for path in integration_receipt.rejected_bundle_receipts], + final_validation=final_validation, + ) + payload = { + "schema": SCHEMA_RELEASE_CANDIDATE, + "release_candidate_id": f"rc-{integration_receipt.run_id}-{integration_receipt.integration_id}", + "integration_id": integration_receipt.integration_id, + "run_id": integration_receipt.run_id, + "status": "ready", + "base_commit": integration_receipt.base_commit, + "target_worktree": rel(target.target_worktree), + "bundle_ids": [plan.bundle_id for plan in plans], + "task_ids": [plan.task_id for plan in plans], + "touched_paths": sorted({path for plan in plans for path in plan.touched_paths}), + "apply_report": out_rel(out_dir / "apply-report.json", out_dir), + "step_receipts": apply_report.get("step_receipts") or [], + "integrated_diff": integrated_diff, + "release_notes": release_notes, + "final_validation_status": "passed", + "created_at": timestamp, + } + write_json(out_dir / "release-candidate.json", payload) + return payload + + +def write_apply_report_md(path: Path, report: dict[str, Any]) -> None: + lines = [ + "# Parallel Delivery Apply Report", + "", + f"- Integration ID: `{report.get('integration_id')}`", + f"- Run ID: `{report.get('run_id')}`", + f"- Mode: `{report.get('mode')}`", + f"- Status: `{report.get('status')}`", + f"- Accepted bundles: `{report.get('accepted_bundle_count')}`", + f"- Applied bundles: `{report.get('applied_bundle_count')}`", + f"- Failed bundle: `{report.get('failed_bundle_id') or 'none'}`", + f"- Rollback metadata: `{report.get('rollback_metadata')}`", + ] + if report.get("release_candidate"): + lines.append(f"- Release candidate: `{report.get('release_candidate')}`") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_validation_summary(root: Path, report: dict[str, Any]) -> None: + path = root / "validation-summary.txt" + previous = path.read_text(encoding="utf-8") if path.exists() else "" + lines = [ + f"integration ID: {report.get('integration_id')}", + f"run ID: {report.get('run_id')}", + f"base commit: {report.get('base_commit')}", + f"{report.get('mode')} status: {report.get('status')}", + f"accepted bundle count: {report.get('accepted_bundle_count')}", + f"applied bundle count: {report.get('applied_bundle_count')}", + f"rejected receipt refusal status: see rejected-receipt.exit-code if present", + f"final validation: {(report.get('final_validation') or {}).get('status')}", + f"release candidate artifact path: {report.get('release_candidate')}", + f"release notes path: {report.get('release_notes')}", + f"rollback metadata path: {report.get('rollback_metadata')}", + "", + ] + path.write_text((previous + "\n" if previous else "") + "\n".join(lines), encoding="utf-8") + + +def create_release_candidate( + *, + integration_receipt_path: Path, + out_dir: Path, + mode: Literal["dry-run", "apply"], + target_repo: Path, + target_worktree: Path | None = None, + expected_base_commit: str | None = None, + final_validation_commands: list[str] | None = None, + use_factory: bool = False, + stop_on_first_failure: bool = True, + fixed_timestamp: str | None = None, +) -> dict[str, Any]: + del stop_on_first_failure + timestamp = fixed_timestamp or now_iso() + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "apply-receipts").mkdir(parents=True, exist_ok=True) + (out_dir / "logs").mkdir(parents=True, exist_ok=True) + receipt = load_integration_receipt(integration_receipt_path) + assert_integration_receipt_accepted(receipt) + plans = load_and_verify_bundle_receipts(receipt, receipt_root=receipt.path.parent, expected_base_commit=expected_base_commit) + target = prepare_integration_target( + target_repo=target_repo, + target_worktree=target_worktree, + base_commit=receipt.base_commit or expected_base_commit or "", + out_dir=out_dir, + mode=mode, + use_factory=use_factory, + ) + + step_receipts: list[str] = [] + applied_bundle_ids: list[str] = [] + failed_bundle_id: str | None = None + failure_reason: str | None = None + final_validation: list[dict[str, Any]] = [] + release_candidate: dict[str, Any] | None = None + + for plan in plans: + dry_run = dry_run_bundle_apply(target, plan, out_dir) + apply_result: dict[str, Any] | None = None + validation: list[dict[str, Any]] = [] + status = "dry_run_passed" + if dry_run["exit_code"] != 0: + status = "dry_run_failed" + failed_bundle_id = plan.bundle_id + failure_reason = "apply_check_failed" + elif mode == "apply": + apply_result = apply_bundle(target, plan, out_dir) + if apply_result["exit_code"] != 0: + status = "apply_failed" + failed_bundle_id = plan.bundle_id + failure_reason = "apply_failed" + else: + validation = run_bundle_validation(target, plan, out_dir) + if any(item["exit_code"] != 0 for item in validation): + status = "validation_failed" + failed_bundle_id = plan.bundle_id + failure_reason = "bundle_validation_failed" + else: + status = "applied" + applied_bundle_ids.append(plan.bundle_id) + step_payload = step_receipt_payload( + receipt=receipt, + target=target, + bundle=plan, + mode=mode, + status=status, + dry_run=dry_run, + apply_result=apply_result, + validation=validation, + timestamp=timestamp, + ) + step_receipts.append(write_step_receipt(out_dir, plan, step_payload)) + if failed_bundle_id: + break + + if failed_bundle_id: + report_status = "dry_run_failed" if mode == "dry-run" else "failed" + elif mode == "dry-run": + report_status = "dry_run_succeeded" + else: + final_commands = normalize_commands(final_validation_commands or []) + receipt.final_validation_commands + final_validation = run_final_validation(target, final_commands, out_dir) + if any(item["exit_code"] != 0 for item in final_validation): + report_status = "final_validation_failed" + failure_reason = "final_validation_failed" + else: + report_status = "succeeded" + + rollback_path = write_rollback_metadata( + out_dir=out_dir, + receipt=receipt, + target=target, + mode=mode, + applied_bundle_ids=applied_bundle_ids, + failed_bundle_id=failed_bundle_id, + failure_reason=failure_reason, + timestamp=timestamp, + ) + + report = { + "schema": SCHEMA_APPLY_REPORT, + "integration_id": receipt.integration_id, + "run_id": receipt.run_id, + "mode": mode, + "status": report_status, + "base_commit": receipt.base_commit, + "target_repo": rel(target.target_repo), + "target_worktree": rel(target.target_worktree), + "accepted_bundle_count": len(plans), + "applied_bundle_count": len(applied_bundle_ids), + "failed_bundle_id": failed_bundle_id, + "stopped_on_first_failure": bool(failed_bundle_id or failure_reason), + "step_receipts": step_receipts, + "final_validation": { + "status": "passed" if final_validation and all(item["exit_code"] == 0 for item in final_validation) else ("skipped" if not final_validation else "failed"), + "commands": final_validation, + }, + "release_candidate": None, + "release_notes": None, + "rollback_metadata": rollback_path, + } + if mode == "apply" and report_status == "succeeded": + release_candidate = create_release_candidate_packet( + integration_receipt=receipt, + apply_report=report, + target=target, + plans=plans, + out_dir=out_dir, + final_validation=final_validation, + timestamp=timestamp, + ) + report["release_candidate"] = out_rel(out_dir / "release-candidate.json", out_dir) + report["release_notes"] = release_candidate["release_notes"] + write_json(out_dir / "apply-report.json", report) + write_apply_report_md(out_dir / "apply-report.md", report) + write_validation_summary(out_dir.parent, report) + return report + + +def write_refusal(out_dir: Path, message: str) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + write_json( + out_dir / "refusal.json", + { + "schema": "cento.parallel_delivery.release_candidate_refusal.v1", + "status": "refused", + "reason": message, + "created_at": now_iso(), + }, + ) + + +def fixture_patch_1() -> str: + return """diff --git a/src/example.py b/src/example.py +--- a/src/example.py ++++ b/src/example.py +@@ -1,2 +1,2 @@ + def greet(): +- return "hello" ++ return "hello patch swarm" +""" + + +def fixture_patch_2() -> str: + return ( + "diff --git a/tests/test_example.py b/tests/test_example.py\n" + "--- a/tests/test_example.py\n" + "+++ b/tests/test_example.py\n" + "@@ -1,4 +1,4 @@\n" + " from src.example import greet\n" + " \n" + " def test_greet():\n" + "- assert greet() == \"hello\"\n" + "+ assert greet() == \"hello patch swarm\"\n" + ) + + +def fixture_patch_rejected() -> str: + return """diff --git a/README.md b/README.md +new file mode 100644 +--- /dev/null ++++ b/README.md +@@ -0,0 +1 @@ ++Rejected patch should never be applied. +""" + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def build_release_candidate_fixture(out_dir: Path, *, base_commit: str, timestamp: str | None = None) -> dict[str, Any]: + timestamp = timestamp or now_iso() + input_dir = out_dir / "input" + patches_dir = input_dir / "patches" + receipts_dir = input_dir / "bundle-receipts" + fixture_repo = out_dir / "fixture-repo" + write_text(fixture_repo / "src" / "__init__.py", "") + write_text(fixture_repo / "src" / "example.py", 'def greet():\n return "hello"\n') + write_text(fixture_repo / "tests" / "test_example.py", 'from src.example import greet\n\ndef test_greet():\n assert greet() == "hello"\n') + + patches = { + "bundle-safe-001": fixture_patch_1(), + "bundle-safe-002": fixture_patch_2(), + "bundle-rejected-001": fixture_patch_rejected(), + } + for bundle_id, text in patches.items(): + write_text(patches_dir / f"{bundle_id}.diff", text) + + receipt_specs = [ + { + "bundle_id": "bundle-safe-001", + "task_id": "task-owned-src", + "worker_id": "worker-a", + "validation_status": "accepted", + "integratable": True, + "patch_path": "../patches/bundle-safe-001.diff", + "touched_paths": ["src/example.py"], + "validation_commands": [{"cmd": "python3 -m py_compile src/example.py"}], + }, + { + "bundle_id": "bundle-safe-002", + "task_id": "task-owned-tests", + "worker_id": "worker-b", + "validation_status": "accepted", + "integratable": True, + "patch_path": "../patches/bundle-safe-002.diff", + "touched_paths": ["tests/test_example.py"], + "validation_commands": [{"cmd": "python3 -m pytest -q tests"}], + }, + { + "bundle_id": "bundle-rejected-001", + "task_id": "task-rejected", + "worker_id": "worker-c", + "validation_status": "rejected", + "integratable": False, + "patch_path": "../patches/bundle-rejected-001.diff", + "touched_paths": ["README.md"], + "validation_commands": [], + }, + ] + receipt_paths = [] + for spec in receipt_specs: + patch_path = patches_dir / f"{spec['bundle_id']}.diff" + payload = { + "schema": SCHEMA_BUNDLE_RECEIPT, + "base_commit": base_commit, + "patch_sha256": sha256_file(patch_path), + **spec, + } + receipt_path = receipts_dir / f"receipt-{spec['bundle_id']}.json" + write_json(receipt_path, payload) + receipt_paths.append(receipt_path) + + accepted = { + "schema": SCHEMA_INTEGRATION_RECEIPT, + "integration_id": "integration-fixture-001", + "run_id": out_dir.name, + "base_commit": base_commit, + "status": "accepted", + "accepted_bundle_receipts": [ + "bundle-receipts/receipt-bundle-safe-001.json", + "bundle-receipts/receipt-bundle-safe-002.json", + ], + "rejected_bundle_receipts": ["bundle-receipts/receipt-bundle-rejected-001.json"], + "apply_order": ["bundle-safe-001", "bundle-safe-002"], + "final_validation_commands": [{"cmd": "python3 -m pytest -q tests"}], + "accepted_by": "local-operator", + "accepted_at": timestamp, + "notes": "Fixture integration receipt.", + } + rejected = {**accepted, "status": "rejected", "integration_id": "integration-fixture-rejected-001"} + write_json(input_dir / "integration-receipt.accepted.json", accepted) + write_json(input_dir / "integration-receipt.rejected.json", rejected) + summary = { + "ok": True, + "run_dir": rel(out_dir), + "base_commit": base_commit, + "integration_receipt": rel(input_dir / "integration-receipt.accepted.json"), + "rejected_integration_receipt": rel(input_dir / "integration-receipt.rejected.json"), + "bundle_receipts": [rel(path) for path in receipt_paths], + "fixture_repo": rel(fixture_repo), + } + write_validation_summary(out_dir, {"integration_id": "integration-fixture-001", "run_id": out_dir.name, "base_commit": base_commit, "mode": "fixture", "status": "created", "accepted_bundle_count": 2, "applied_bundle_count": 0}) + return summary + + +def add_create_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--integration-receipt", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("--mode", choices=["dry-run", "apply"], default="dry-run") + parser.add_argument("--target-repo", default=str(ROOT)) + parser.add_argument("--target-worktree", default="") + parser.add_argument("--base-commit", default="") + parser.add_argument("--use-factory-worktree", action="store_true") + parser.add_argument("--final-validation-cmd", action="append", default=[]) + parser.add_argument("--json", action="store_true") + + +def run_create_from_args(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + out_dir = Path(args.out) + try: + report = create_release_candidate( + integration_receipt_path=Path(args.integration_receipt), + out_dir=out_dir, + mode=args.mode, + target_repo=Path(args.target_repo), + target_worktree=Path(args.target_worktree) if args.target_worktree else None, + expected_base_commit=resolve_expected_base_commit(args.base_commit), + final_validation_commands=list(args.final_validation_cmd or []), + use_factory=bool(args.use_factory_worktree), + ) + except ReleaseCandidateError as exc: + write_refusal(out_dir, str(exc)) + return {"ok": False, "status": "refused", "error": str(exc), "out": rel(out_dir)}, 1 + ok = report["status"] in {"dry_run_succeeded", "succeeded"} + return {"ok": ok, **report}, 0 if ok else 1 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Create Parallel Delivery release-candidate evidence from accepted integration receipts.") + sub = parser.add_subparsers(dest="command", required=True) + create = sub.add_parser("create") + add_create_args(create) + fixture = sub.add_parser("fixture") + fixture.add_argument("--out", required=True) + fixture.add_argument("--base-commit", required=True) + fixture.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + if args.command == "fixture": + payload = build_release_candidate_fixture(Path(args.out), base_commit=resolve_expected_base_commit(args.base_commit) or args.base_commit) + print(stable_json_dumps(payload) if args.json else payload["run_dir"], end="" if args.json else "\n") + return 0 + payload, code = run_create_from_args(args) + print(stable_json_dumps(payload) if args.json else payload.get("status", "failed"), end="" if args.json else "\n") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_taskstream.py b/scripts/parallel_delivery_taskstream.py new file mode 100644 index 0000000..21b3c08 --- /dev/null +++ b/scripts/parallel_delivery_taskstream.py @@ -0,0 +1,1033 @@ +#!/usr/bin/env python3 +"""Patch Swarm to agent-work/Taskstream handoff adapter. + +The adapter is deliberately local-first. It generates story and validation +manifests that existing `cento agent-work preflight` understands, plus Patch +Swarm metadata for traceability. Live Taskstream changes are only attempted by +the explicit apply path. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shlex +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal + +try: + import story_manifest + import validation_manifest as validation_manifest_tools +except ImportError: # pragma: no cover - direct import fallback for unusual cwd + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import story_manifest + import validation_manifest as validation_manifest_tools + + +ROOT = Path(__file__).resolve().parents[1] +TASKSTREAM_REPORT_SCHEMA = "cento.parallel_delivery.taskstream_handoff_report.v1" +TASKSTREAM_APPLY_RECEIPT_SCHEMA = "cento.parallel_delivery.taskstream_apply_receipt.v1" +PATCH_SWARM_SPLIT_SCHEMA = "cento.parallel_delivery.split_plan.v1" +STORY_COMPAT_SCHEMA = "cento.agent_work.story.v1" +VALIDATION_COMPAT_SCHEMA = "cento.agent_work.validation.v1" +DEFAULT_TIMESTAMP = "2026-01-01T00:00:00Z" +SECRET_PATH_NAMES = { + ".env", + ".env.mcp", + ".env.local", + ".env.production", + ".env.development", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", +} + + +class TaskstreamHandoffError(Exception): + """Raised when Patch Swarm handoff artifacts cannot be generated safely.""" + + +@dataclass(frozen=True) +class ValidationIssue: + field: str + message: str + + +@dataclass(frozen=True) +class PatchSwarmTask: + task_id: str + title: str + summary: str + route: str + worker_profile: str | None + priority: str + owned_paths: list[str] + touched_path_candidates: list[str] + acceptance_contract: list[str] + validation_commands: list[str] + evidence_files: list[str] + handoff_notes: str + risk_flags: list[str] + lane: str + risk_tier: str + state: str + + +@dataclass(frozen=True) +class PatchSwarmSplitPlan: + schema: str + run_id: str + request_id: str + title: str + base_commit: str | None + tasks: list[PatchSwarmTask] + + +@dataclass(frozen=True) +class TaskstreamHandoffReport: + schema: str + run_id: str + request_id: str + mode: str + transport: str + split_plan: str + task_count: int + story_manifest_count: int + validation_manifest_count: int + agent_work_routed_count: int + manifest_only_count: int + preflight: dict[str, Any] + live_creation_attempted: bool + live_creation_blocked_without_apply: bool + tasks: list[dict[str, Any]] + created_at: str + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json_dumps(payload: dict[str, Any]) -> str: + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json_dumps(payload), encoding="utf-8") + + +def resolve_root_path(path: str | Path) -> Path: + candidate = Path(path) + return candidate if candidate.is_absolute() else ROOT / candidate + + +def rel(path: Path) -> str: + try: + return str(path.resolve().relative_to(ROOT)) + except ValueError: + return str(path) + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip("-") + return slug or "patch-swarm-task" + + +def text_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] if value.strip() else [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [str(value).strip()] if str(value).strip() else [] + + +def looks_like_secret_value(value: str) -> bool: + text = str(value or "") + return bool( + re.search(r"\bsk-[A-Za-z0-9_-]{20,}\b", text) + or re.search(r"BEGIN [A-Z ]*PRIVATE KEY", text) + or re.search(r"(?i)(api[_-]?key|secret|token)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{16,}", text) + ) + + +def normalize_safe_manifest_path(raw: str) -> str: + value = str(raw or "").replace("\\", "/").strip() + if not value: + raise TaskstreamHandoffError("path is required") + if "\x00" in value: + raise TaskstreamHandoffError(f"path contains NUL byte: {raw!r}") + if value.startswith("/"): + raise TaskstreamHandoffError(f"absolute paths are not allowed: {raw}") + if re.match(r"^[A-Za-z]:/", value): + raise TaskstreamHandoffError(f"Windows drive paths are not allowed: {raw}") + parts = [part for part in value.split("/") if part not in {"", "."}] + if any(part == ".." for part in parts): + raise TaskstreamHandoffError(f"path traversal is not allowed: {raw}") + for part in parts: + lower = part.lower() + if lower in SECRET_PATH_NAMES or lower.startswith(".env."): + raise TaskstreamHandoffError(f"local secret path is not allowed: {raw}") + if lower in {"secrets", "private_keys"}: + raise TaskstreamHandoffError(f"local secret-looking path is not allowed: {raw}") + if looks_like_secret_value(value): + raise TaskstreamHandoffError("secret-looking inline value is not allowed in manifest paths") + return "/".join(parts) + + +def safe_path_list(values: list[str], field: str) -> list[str]: + normalized: list[str] = [] + for value in values: + try: + normalized.append(normalize_safe_manifest_path(value)) + except TaskstreamHandoffError as exc: + raise TaskstreamHandoffError(f"{field}: {exc}") from exc + return sorted(dict.fromkeys(normalized)) + + +def task_from_payload(payload: dict[str, Any], index: int) -> PatchSwarmTask: + task_id = str(payload.get("task_id") or payload.get("id") or f"task-{index:04d}").strip() + title = str(payload.get("title") or payload.get("name") or task_id).strip() + summary = str(payload.get("summary") or payload.get("description") or payload.get("story") or title).strip() + route = str(payload.get("route") or payload.get("taskstream_route") or "").strip() + lane = str(payload.get("lane") or payload.get("role") or "").strip() + worker_profile = str(payload.get("worker_profile") or payload.get("worker_profile_suggestion") or "").strip() or None + priority = str(payload.get("priority") or "normal").strip() or "normal" + risk_tier = str(payload.get("risk_tier") or payload.get("risk") or "medium").strip() or "medium" + state = str(payload.get("state") or "ready").strip() or "ready" + owned_paths = text_list(payload.get("owned_paths") or payload.get("write_paths") or payload.get("owned_scope")) + touched = text_list( + payload.get("touched_path_candidates") + or payload.get("touched_paths") + or payload.get("changed_paths") + or payload.get("expected_artifacts") + ) + if not touched: + touched = list(owned_paths) + acceptance = text_list(payload.get("acceptance_contract") or payload.get("acceptance_criteria") or payload.get("acceptance")) + validation_commands = text_list(payload.get("validation_commands") or payload.get("commands")) + evidence_files = text_list(payload.get("evidence_files") or payload.get("evidence_pointers") or payload.get("expected_evidence")) + if not evidence_files: + evidence_files = [f"workspace/runs/parallel-delivery/taskstream-fixture/evidence/{task_id}-validation.txt"] + handoff_notes = str(payload.get("handoff_notes") or payload.get("handoff") or "Preserve unrelated hunks and leave deterministic evidence.").strip() + risk_flags = text_list(payload.get("risk_flags") or payload.get("rejection_triggers")) + return PatchSwarmTask( + task_id=task_id, + title=title, + summary=summary, + route=route, + worker_profile=worker_profile, + priority=priority, + owned_paths=safe_path_list(owned_paths, "owned_paths"), + touched_path_candidates=safe_path_list(touched, "touched_path_candidates"), + acceptance_contract=acceptance, + validation_commands=validation_commands, + evidence_files=safe_path_list(evidence_files, "evidence_files"), + handoff_notes=handoff_notes, + risk_flags=risk_flags, + lane=lane, + risk_tier=risk_tier, + state=state, + ) + + +def load_split_plan(path: Path) -> PatchSwarmSplitPlan: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise TaskstreamHandoffError(f"split plan not found: {path}") from exc + except json.JSONDecodeError as exc: + raise TaskstreamHandoffError(f"invalid split plan JSON in {path}: {exc}") from exc + if not isinstance(payload, dict): + raise TaskstreamHandoffError("split plan root must be an object") + + raw_tasks = payload.get("tasks") + if raw_tasks is None and isinstance(payload.get("task_graph"), dict): + raw_tasks = payload["task_graph"].get("tasks") or payload["task_graph"].get("nodes") + if raw_tasks is None: + raw_tasks = payload.get("nodes") + if not isinstance(raw_tasks, list) or not raw_tasks: + raise TaskstreamHandoffError("split plan must include a non-empty tasks list") + tasks = [] + for index, item in enumerate(raw_tasks, start=1): + if not isinstance(item, dict): + raise TaskstreamHandoffError(f"task #{index} must be an object") + tasks.append(task_from_payload(item, index)) + + request = payload.get("request") if isinstance(payload.get("request"), dict) else {} + return PatchSwarmSplitPlan( + schema=str(payload.get("schema") or payload.get("schema_version") or PATCH_SWARM_SPLIT_SCHEMA), + run_id=str(payload.get("run_id") or path.parent.name or "taskstream-handoff"), + request_id=str(payload.get("request_id") or request.get("id") or f"request-{path.parent.name}"), + title=str(payload.get("title") or request.get("title") or request.get("normalized_goal") or "Patch Swarm taskstream handoff"), + base_commit=str(payload.get("base_commit") or payload.get("base_ref") or ""), + tasks=tasks, + ) + + +def choose_task_route(task: PatchSwarmTask, *, default_route: str = "agent-work") -> str: + explicit = str(task.route or "").strip().lower() + if explicit in {"manifest-only", "evidence-only", "planning-only", "blocked", "no-live-create"}: + return "manifest-only" + if explicit == "agent-work": + return "agent-work" if task.validation_commands else "manifest-only" + if task.state.lower() in {"blocked", "planning", "evidence-only"}: + return "manifest-only" + lane = task.lane.lower() + if lane in {"human-handoff", "docs-evidence"} and not task.validation_commands: + return "manifest-only" + if task.validation_commands and task.acceptance_contract and default_route == "agent-work": + return "agent-work" + return "manifest-only" + + +def validate_patch_swarm_task(task: PatchSwarmTask, *, route: str | None = None) -> list[ValidationIssue]: + issues: list[ValidationIssue] = [] + selected_route = route or choose_task_route(task) + if not task.task_id: + issues.append(ValidationIssue("task_id", "task_id is required")) + if not task.title: + issues.append(ValidationIssue("title", "title is required")) + if not task.acceptance_contract: + issues.append(ValidationIssue("acceptance_contract", "acceptance contract is required")) + if selected_route == "agent-work" and not task.validation_commands: + issues.append(ValidationIssue("validation_commands", "validation commands are required for agent-work tasks")) + for field, values in ( + ("owned_paths", task.owned_paths), + ("touched_path_candidates", task.touched_path_candidates), + ("evidence_files", task.evidence_files), + ): + for value in values: + try: + normalize_safe_manifest_path(value) + except TaskstreamHandoffError as exc: + issues.append(ValidationIssue(field, str(exc))) + for field, values in ( + ("title", [task.title]), + ("summary", [task.summary]), + ("acceptance_contract", task.acceptance_contract), + ("validation_commands", task.validation_commands), + ("handoff_notes", [task.handoff_notes]), + ): + for value in values: + if looks_like_secret_value(value): + issues.append(ValidationIssue(field, "secret-looking value is not allowed")) + return issues + + +def role_for_task(task: PatchSwarmTask, route: str) -> str: + text = " ".join([task.lane, task.worker_profile or "", route]).lower() + if "validator" in text: + return "validator" + if "coordinator" in text or "planner" in text: + return "coordinator" + if "docs" in text or "evidence" in text: + return "docs-evidence" + return "builder" + + +def risk_for_story(task: PatchSwarmTask) -> str: + risk = task.risk_tier.lower() + return risk if risk in {"low", "medium", "high"} else "medium" + + +def task_story_key(task: PatchSwarmTask) -> str: + return f"patch-swarm-{slugify(task.task_id)}" + + +def task_run_dir(out_dir: Path, task: PatchSwarmTask) -> str: + return rel(out_dir / "work-packages" / task.task_id) + + +def task_to_story_manifest(plan: PatchSwarmSplitPlan, task: PatchSwarmTask, *, out_dir: Path, route: str) -> dict[str, Any]: + run_dir = task_run_dir(out_dir, task) + validation_path = f"{run_dir}/validation.json" + deliverables_path = f"{run_dir}/deliverables.json" + hub_path = f"{run_dir}/start-here.html" + role = role_for_task(task, route) + risk = risk_for_story(task) + if not task.validation_commands: + validation_mode = "manual-planning" + elif risk == "high": + validation_mode = "strong-model" + else: + validation_mode = "no-model" + no_model = validation_mode == "no-model" + commands = task.validation_commands if task.validation_commands else [] + expected_outputs = [ + { + "path": value, + "owner": role, + "description": f"Expected evidence for {task.task_id}", + "required": True, + } + for value in task.evidence_files + ] + expected_outputs.append( + { + "path": f"{run_dir}/handoff.md", + "owner": role, + "description": "Patch Swarm task handoff note.", + "required": True, + } + ) + return { + "schema": STORY_COMPAT_SCHEMA, + "schema_version": "1.0", + "source": "parallel-delivery", + "run_id": plan.run_id, + "request_id": plan.request_id, + "task_id": task.task_id, + "story_key": task_story_key(task), + "title": task.title, + "summary": task.summary, + "status": "ready_for_agent_work" if route == "agent-work" else "manifest_only", + "route": route, + "worker_profile": task.worker_profile or "", + "priority": task.priority, + "owned_paths": task.owned_paths, + "touched_path_candidates": task.touched_path_candidates, + "acceptance_contract": task.acceptance_contract, + "handoff_notes_path": "handoff.md", + "validation_manifest_path": "validation.json", + "evidence_links": task.evidence_files, + "risk_flags": task.risk_flags, + "issue": { + "id": 0, + "title": task.title, + "package": f"patch-swarm-{slugify(plan.run_id)}", + }, + "lane": { + "owner": role, + "node": "linux", + "agent": task.worker_profile or "codex", + "role": role, + }, + "paths": { + "run_dir": run_dir, + }, + "scope": { + "goal": task.summary, + "acceptance": task.acceptance_contract, + }, + "expected_outputs": expected_outputs, + "validation": { + "manifest": validation_path, + "mode": validation_mode, + "risk": risk, + "no_model_eligible": no_model, + "escalation_triggers": [ + "missing_manifest", + "failed_deterministic_command", + "ambiguity", + ], + "commands": commands, + }, + "deliverables": { + "manifest": deliverables_path, + "hub": hub_path, + }, + "review_gate": { + "required_sections": ["Delivered", "Validation", "Evidence", "Residual risk"], + "residual_risk_required": True, + }, + "metadata": { + "base_commit": plan.base_commit or "", + "created_by": "cento parallel-delivery taskstream emit", + "split_plan_schema": plan.schema, + }, + } + + +def task_to_validation_manifest(plan: PatchSwarmSplitPlan, task: PatchSwarmTask, story: dict[str, Any], story_path: Path) -> dict[str, Any]: + if task.validation_commands: + manifest = validation_manifest_tools.build_manifest(story, story_path) + else: + manifest = { + "schema": "cento.validation-manifest.v1", + "task": task.title, + "story_manifest": rel(story_path), + "claim": task.summary, + "risk": risk_for_story(task), + "decision_requested": "approve", + "checks": [ + { + "name": "story-json-valid", + "type": "command", + "command": f"python3 -m json.tool {shlex.quote(rel(story_path))}", + "cwd": ".", + "timeout_seconds": 20, + "expect_exit": 0, + "required": True, + } + ], + "manual_review": [], + "coverage": { + "deterministic_checks": 1, + "manual_review_items": 0, + "automation_coverage_percent": 100.0, + }, + "stats_policy": { + "ai_calls_used": 0, + "estimated_ai_cost": 0, + "requires_total_duration_ms": True, + "requires_per_check_duration_ms": True, + }, + "created_at": utc_now(), + } + manifest.update( + { + "compat_schema": VALIDATION_COMPAT_SCHEMA, + "source": "parallel-delivery", + "run_id": plan.run_id, + "task_id": task.task_id, + "story_key": task_story_key(task), + "validation_commands": [ + {"cmd": command, "required": True, "working_directory": "repo"} + for command in task.validation_commands + ], + "expected_evidence_files": task.evidence_files, + "acceptance_contract": task.acceptance_contract, + "record_back": { + "preferred_transport": "mcp", + "fallback_transport": "agent-work", + "live_update_requires_apply": True, + }, + } + ) + return manifest + + +def write_handoff_note(path: Path, task: PatchSwarmTask) -> None: + lines = [ + f"# Patch Swarm Handoff: {task.task_id}", + "", + "## Scope", + task.summary, + "", + "## Owned Paths", + ] + lines.extend(f"- {item}" for item in (task.owned_paths or ["None declared."])) + lines.extend(["", "## Candidate Touched Paths"]) + lines.extend(f"- {item}" for item in (task.touched_path_candidates or ["None declared."])) + lines.extend(["", "## Acceptance Contract"]) + lines.extend(f"- {item}" for item in task.acceptance_contract) + lines.extend(["", "## Validation"]) + lines.extend(f"- {item}" for item in (task.validation_commands or ["Manifest-only task; inspect generated evidence links."])) + lines.extend(["", "## Evidence"]) + lines.extend(f"- {item}" for item in task.evidence_files) + lines.extend( + [ + "", + "## Notes", + task.handoff_notes, + "", + "## Guards", + "Preserve unrelated dirty work. Do not edit secrets. Do not mutate Taskstream/Redmine directly.", + ] + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def agent_work_create_command(story_path: Path, task: PatchSwarmTask, route: str) -> str: + if route != "agent-work": + return "manifest-only: no live agent-work create command is planned for this task." + cmd = [ + "cento", + "agent-work", + "create", + "--title", + task.title, + "--manifest", + rel(story_path), + "--description", + task.summary, + "--node", + "linux", + "--agent", + task.worker_profile or "codex", + "--role", + role_for_task(task, route), + "--package", + "parallel-delivery", + ] + for owned in task.owned_paths: + cmd.extend(["--owns", owned]) + return " ".join(shlex.quote(part) for part in cmd) + + +def discover_agent_work_preflight() -> dict[str, Any] | None: + script = ROOT / "scripts" / "agent_work.py" + if not script.exists(): + return None + return { + "command": f"{sys.executable} {rel(script)} preflight STORY --validation-manifest VALIDATION", + "script": rel(script), + } + + +def preflight_one_package(package_dir: Path, report_dir: Path) -> dict[str, Any]: + story_path = package_dir / "story.json" + validation_path = package_dir / "validation.json" + task_id = package_dir.name + report_path = report_dir / f"{task_id}.preflight.json" + stdout_path = report_dir / f"{task_id}.preflight.stdout" + stderr_path = report_dir / f"{task_id}.preflight.stderr" + cmd = [ + sys.executable, + str(ROOT / "scripts" / "agent_work.py"), + "preflight", + str(story_path), + "--validation-manifest", + str(validation_path), + "--report", + str(report_path), + "--json", + ] + report_dir.mkdir(parents=True, exist_ok=True) + completed = subprocess.run(cmd, cwd=ROOT, text=True, capture_output=True, check=False) + stdout_path.write_text(completed.stdout, encoding="utf-8") + stderr_path.write_text(completed.stderr, encoding="utf-8") + return { + "task_id": task_id, + "status": "passed" if completed.returncode == 0 else "blocked", + "command": " ".join(shlex.quote(part) for part in cmd), + "exit_code": completed.returncode, + "report": rel(report_path), + "stdout_path": rel(stdout_path), + "stderr_path": rel(stderr_path), + } + + +def run_agent_work_preflight(manifest_dir: Path, out_dir: Path) -> dict[str, Any]: + discovery = discover_agent_work_preflight() + if discovery is None: + return { + "available": False, + "status": "skipped", + "command": "", + "exit_code": 0, + "stdout_path": "", + "stderr_path": "", + "tasks": [], + } + package_dirs = sorted(path for path in manifest_dir.iterdir() if path.is_dir()) + report_dir = out_dir / "preflight-reports" + task_results = [preflight_one_package(path, report_dir) for path in package_dirs] + status = "passed" if all(item["exit_code"] == 0 for item in task_results) else "blocked" + summary_stdout = out_dir / "logs" / "agent-work-preflight.stdout" + summary_stderr = out_dir / "logs" / "agent-work-preflight.stderr" + summary_stdout.parent.mkdir(parents=True, exist_ok=True) + summary_stdout.write_text("\n".join(f"{item['task_id']}: {item['status']} exit={item['exit_code']}" for item in task_results) + "\n", encoding="utf-8") + summary_stderr.write_text("\n".join(item["stderr_path"] for item in task_results if item["exit_code"] != 0) + ("\n" if task_results else ""), encoding="utf-8") + return { + "available": True, + "status": status, + "command": discovery["command"], + "exit_code": 0 if status == "passed" else 1, + "stdout_path": rel(summary_stdout), + "stderr_path": rel(summary_stderr), + "tasks": task_results, + } + + +def validate_manifest_dir(manifest_dir: Path) -> list[str]: + errors: list[str] = [] + if not manifest_dir.exists(): + return [f"manifest dir not found: {manifest_dir}"] + for package_dir in sorted(path for path in manifest_dir.iterdir() if path.is_dir()): + story_path = package_dir / "story.json" + validation_path = package_dir / "validation.json" + handoff_path = package_dir / "handoff.md" + for path in (story_path, validation_path, handoff_path): + if not path.exists(): + errors.append(f"missing {path.name}: {rel(path)}") + if story_path.exists(): + try: + story = story_manifest.load_manifest(story_path) + errors.extend(f"{package_dir.name} story: {item}" for item in story_manifest.validate_manifest(story, check_links=False)) + except Exception as exc: # noqa: BLE001 - surfaced as preflight error text + errors.append(f"{package_dir.name} story: {exc}") + if validation_path.exists(): + try: + validation = validation_manifest_tools.load_validation(validation_path) + errors.extend(f"{package_dir.name} validation: {item}" for item in validation_manifest_tools.validate_validation_manifest(validation)) + except Exception as exc: # noqa: BLE001 - surfaced as preflight error text + errors.append(f"{package_dir.name} validation: {exc}") + return errors + + +def taskstream_report_payload(report: TaskstreamHandoffReport) -> dict[str, Any]: + return { + "schema": report.schema, + "run_id": report.run_id, + "request_id": report.request_id, + "mode": report.mode, + "transport": report.transport, + "split_plan": report.split_plan, + "task_count": report.task_count, + "story_manifest_count": report.story_manifest_count, + "validation_manifest_count": report.validation_manifest_count, + "agent_work_routed_count": report.agent_work_routed_count, + "manifest_only_count": report.manifest_only_count, + "preflight": report.preflight, + "live_creation_attempted": report.live_creation_attempted, + "live_creation_blocked_without_apply": report.live_creation_blocked_without_apply, + "tasks": report.tasks, + "created_at": report.created_at, + } + + +def write_handoff_report(report: TaskstreamHandoffReport, out_dir: Path) -> None: + payload = taskstream_report_payload(report) + write_json(out_dir / "taskstream-handoff-report.json", payload) + lines = [ + "# Patch Swarm Taskstream Handoff", + "", + f"- Run ID: `{report.run_id}`", + f"- Mode: `{report.mode}`", + f"- Transport: `{report.transport}`", + f"- Tasks: `{report.task_count}`", + f"- Agent-work routed: `{report.agent_work_routed_count}`", + f"- Manifest-only: `{report.manifest_only_count}`", + f"- Preflight: `{report.preflight.get('status', 'unknown')}`", + f"- Live creation attempted: `{str(report.live_creation_attempted).lower()}`", + "", + "## Work Packages", + "", + ] + for item in report.tasks: + lines.append(f"- `{item['task_id']}` route=`{item['route']}` story=`{item['story_manifest']}` validation=`{item['validation_manifest']}`") + (out_dir / "taskstream-handoff-report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + write_validation_summary(report, out_dir) + + +def write_validation_summary(report: TaskstreamHandoffReport, out_dir: Path) -> None: + lines = [ + f"run ID: {report.run_id}", + f"base commit: {(report.tasks[0].get('base_commit') if report.tasks else '') or 'unknown'}", + f"split plan path: {report.split_plan}", + f"generated work package count: {report.task_count}", + f"story manifest count: {report.story_manifest_count}", + f"validation manifest count: {report.validation_manifest_count}", + f"agent-work routed count: {report.agent_work_routed_count}", + f"manifest-only count: {report.manifest_only_count}", + f"preflight availability/status: {report.preflight.get('available')} / {report.preflight.get('status')}", + f"live creation attempted: {str(report.live_creation_attempted).lower()}", + f"live creation refusal exit code: {'blocked' if report.live_creation_blocked_without_apply else 'not-applicable'}", + f"report paths: {rel(out_dir / 'taskstream-handoff-report.json')}, {rel(out_dir / 'taskstream-handoff-report.md')}", + ] + (out_dir / "validation-summary.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def emit_taskstream_manifests( + *, + split_plan_path: Path, + out_dir: Path, + mode: Literal["dry-run", "apply"] = "dry-run", + transport: Literal["auto", "mcp", "agent-work", "manifest-only"] = "auto", + run_preflight: bool = True, + default_route: str = "agent-work", + timestamp: str | None = None, +) -> TaskstreamHandoffReport: + out_dir.mkdir(parents=True, exist_ok=True) + plan = load_split_plan(split_plan_path) + packages_dir = out_dir / "work-packages" + packages_dir.mkdir(parents=True, exist_ok=True) + task_rows: list[dict[str, Any]] = [] + agent_work_count = 0 + manifest_only_count = 0 + + for task in plan.tasks: + route = choose_task_route(task, default_route=default_route) + issues = validate_patch_swarm_task(task, route=route) + if issues: + detail = "; ".join(f"{issue.field}: {issue.message}" for issue in issues) + raise TaskstreamHandoffError(f"invalid task {task.task_id}: {detail}") + package_dir = packages_dir / task.task_id + package_dir.mkdir(parents=True, exist_ok=True) + story_path = package_dir / "story.json" + validation_path = package_dir / "validation.json" + handoff_path = package_dir / "handoff.md" + story = task_to_story_manifest(plan, task, out_dir=out_dir, route=route) + validation = task_to_validation_manifest(plan, task, story, story_path) + write_json(story_path, story) + write_json(validation_path, validation) + write_handoff_note(handoff_path, task) + command_path = package_dir / "agent-work-command.txt" + command_path.write_text(agent_work_create_command(story_path, task, route) + "\n", encoding="utf-8") + if route == "agent-work": + agent_work_count += 1 + else: + manifest_only_count += 1 + task_rows.append( + { + "task_id": task.task_id, + "route": route, + "story_manifest": rel(story_path), + "validation_manifest": rel(validation_path), + "handoff": rel(handoff_path), + "agent_work_command": rel(command_path), + "base_commit": plan.base_commit or "", + } + ) + + preflight = ( + run_agent_work_preflight(packages_dir, out_dir) + if run_preflight + else {"available": bool(discover_agent_work_preflight()), "status": "skipped", "command": "", "exit_code": 0} + ) + report = TaskstreamHandoffReport( + schema=TASKSTREAM_REPORT_SCHEMA, + run_id=plan.run_id, + request_id=plan.request_id, + mode=mode, + transport=transport, + split_plan=rel(split_plan_path), + task_count=len(plan.tasks), + story_manifest_count=len(plan.tasks), + validation_manifest_count=len(plan.tasks), + agent_work_routed_count=agent_work_count, + manifest_only_count=manifest_only_count, + preflight=preflight, + live_creation_attempted=False, + live_creation_blocked_without_apply=True, + tasks=task_rows, + created_at=timestamp or utc_now(), + ) + write_handoff_report(report, out_dir) + return report + + +def run_preflight_command(manifest_dir: Path, out_dir: Path) -> dict[str, Any]: + out_dir.mkdir(parents=True, exist_ok=True) + errors = validate_manifest_dir(manifest_dir) + agent_work = run_agent_work_preflight(manifest_dir, out_dir) if not errors else {"available": True, "status": "blocked", "exit_code": 1, "tasks": []} + payload = { + "schema": "cento.parallel_delivery.taskstream_preflight.v1", + "manifest_dir": rel(manifest_dir), + "status": "passed" if not errors and agent_work.get("status") == "passed" else "blocked", + "errors": errors, + "agent_work_preflight": agent_work, + "created_at": utc_now(), + } + write_json(out_dir / "preflight-report.json", payload) + lines = ["# Patch Swarm Taskstream Preflight", "", f"- Status: `{payload['status']}`", f"- Manifest dir: `{payload['manifest_dir']}`", ""] + if errors: + lines.extend(["## Errors", ""]) + lines.extend(f"- {item}" for item in errors) + (out_dir / "preflight-report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + return payload + + +def apply_taskstream_handoff( + *, + manifest_dir: Path, + out_dir: Path, + transport: Literal["auto", "mcp", "agent-work"], + apply: bool, +) -> list[dict[str, Any]]: + out_dir.mkdir(parents=True, exist_ok=True) + if not apply: + receipt = { + "schema": TASKSTREAM_APPLY_RECEIPT_SCHEMA, + "mode": "dry-run", + "transport": transport, + "status": "refused", + "reason": "taskstream apply requires explicit --apply", + "manifest_dir": rel(manifest_dir), + "created_at": utc_now(), + } + write_json(out_dir / "apply-refusal.json", receipt) + raise TaskstreamHandoffError("taskstream apply requires explicit --apply") + + receipts: list[dict[str, Any]] = [] + receipts_dir = out_dir / "apply-receipts" + receipts_dir.mkdir(parents=True, exist_ok=True) + for package_dir in sorted(path for path in manifest_dir.iterdir() if path.is_dir()): + story_path = package_dir / "story.json" + validation_path = package_dir / "validation.json" + story = json.loads(story_path.read_text(encoding="utf-8")) + route = str(story.get("route") or "") + if route != "agent-work": + status = "skipped" + command = "manifest-only" + returncode = 0 + stdout = "" + stderr = "" + else: + command = (package_dir / "agent-work-command.txt").read_text(encoding="utf-8").strip() + completed = subprocess.run(shlex.split(command), cwd=ROOT, text=True, capture_output=True, check=False) + returncode = completed.returncode + stdout = completed.stdout + stderr = completed.stderr + status = "submitted" if returncode == 0 else "blocked" + stdout_path = receipts_dir / f"{package_dir.name}.apply.stdout" + stderr_path = receipts_dir / f"{package_dir.name}.apply.stderr" + stdout_path.write_text(stdout, encoding="utf-8") + stderr_path.write_text(stderr, encoding="utf-8") + receipt = { + "schema": TASKSTREAM_APPLY_RECEIPT_SCHEMA, + "run_id": str(story.get("run_id") or ""), + "task_id": package_dir.name, + "mode": "apply", + "transport": "agent-work" if transport == "auto" else transport, + "status": status, + "story_manifest": rel(story_path), + "validation_manifest": rel(validation_path), + "external_ref": {}, + "command_or_tool": command, + "exit_code": returncode, + "stdout_path": rel(stdout_path), + "stderr_path": rel(stderr_path), + "created_at": utc_now(), + } + write_json(receipts_dir / f"{package_dir.name}.json", receipt) + receipts.append(receipt) + write_json( + out_dir / "apply-report.json", + { + "schema": "cento.parallel_delivery.taskstream_apply_report.v1", + "manifest_dir": rel(manifest_dir), + "transport": transport, + "submitted_count": len([item for item in receipts if item["status"] == "submitted"]), + "blocked_count": len([item for item in receipts if item["status"] == "blocked"]), + "skipped_count": len([item for item in receipts if item["status"] == "skipped"]), + "receipts": [rel(receipts_dir / f"{item['task_id']}.json") for item in receipts], + "created_at": utc_now(), + }, + ) + return receipts + + +def run_emit_from_args(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + try: + report = emit_taskstream_manifests( + split_plan_path=resolve_root_path(args.split_plan), + out_dir=resolve_root_path(args.out), + mode="dry-run", + transport=args.transport, + run_preflight=bool(args.run_preflight), + default_route=args.default_route, + ) + return taskstream_report_payload(report), 0 + except TaskstreamHandoffError as exc: + return {"ok": False, "errors": [str(exc)]}, 1 + + +def run_preflight_from_args(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + payload = run_preflight_command(resolve_root_path(args.manifest_dir), resolve_root_path(args.out)) + return payload, 0 if payload.get("status") == "passed" else 1 + + +def run_apply_from_args(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + try: + receipts = apply_taskstream_handoff( + manifest_dir=resolve_root_path(args.manifest_dir), + out_dir=resolve_root_path(args.out), + transport=args.transport, + apply=bool(args.apply), + ) + return {"ok": True, "receipts": receipts}, 0 + except TaskstreamHandoffError as exc: + return {"ok": False, "errors": [str(exc)]}, 2 + + +def add_taskstream_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + taskstream = sub.add_parser("taskstream", help="Emit Patch Swarm task handoff manifests for cento agent-work.") + taskstream_sub = taskstream.add_subparsers(dest="taskstream_command", required=True) + + emit = taskstream_sub.add_parser("emit", help="Generate local story/validation manifests from a Patch Swarm split plan.") + emit.add_argument("--split-plan", required=True) + emit.add_argument("--out", required=True) + emit.add_argument("--transport", choices=["auto", "mcp", "agent-work", "manifest-only"], default="manifest-only") + emit.add_argument("--run-preflight", action=argparse.BooleanOptionalAction, default=True) + emit.add_argument("--default-route", choices=["agent-work", "manifest-only"], default="agent-work") + emit.add_argument("--json", action="store_true") + + preflight = taskstream_sub.add_parser("preflight", help="Validate generated work packages and run safe agent-work preflight.") + preflight.add_argument("--manifest-dir", required=True) + preflight.add_argument("--out", required=True) + preflight.add_argument("--json", action="store_true") + + apply_parser = taskstream_sub.add_parser("apply", help="Submit generated work packages through approved Taskstream surfaces.") + apply_parser.add_argument("--manifest-dir", required=True) + apply_parser.add_argument("--out", required=True) + apply_parser.add_argument("--transport", choices=["auto", "mcp", "agent-work"], default="auto") + apply_parser.add_argument("--apply", action="store_true", help="Required for live task creation.") + apply_parser.add_argument("--json", action="store_true") + + +def build_fixture_split_plan(out_dir: Path, *, base_commit: str, timestamp: str = DEFAULT_TIMESTAMP) -> Path: + input_dir = out_dir / "input" + input_dir.mkdir(parents=True, exist_ok=True) + split_plan_path = input_dir / "split-plan.json" + payload = { + "schema": PATCH_SWARM_SPLIT_SCHEMA, + "run_id": "taskstream-fixture", + "request_id": "request-taskstream-fixture", + "title": "Fixture Patch Swarm taskstream handoff", + "base_commit": base_commit, + "created_at": timestamp, + "tasks": [ + { + "task_id": "task-src-helper", + "title": "Add bounded helper", + "summary": "Implement a small helper in owned fixture source.", + "route": "agent-work", + "worker_profile": "codex", + "priority": "normal", + "owned_paths": ["src/fixture_helper.py"], + "touched_path_candidates": ["src/fixture_helper.py", "tests/test_fixture_helper.py"], + "acceptance_contract": ["Helper exists and returns deterministic output.", "Targeted pytest passes."], + "validation_commands": ["python3 -m json.tool workspace/runs/parallel-delivery/taskstream-fixture/work-packages/task-src-helper/story.json"], + "evidence_files": ["workspace/runs/parallel-delivery/taskstream-fixture/evidence/task-src-helper-validation.txt"], + "handoff_notes": "Bounded implementation task. Preserve unrelated hunks.", + "risk_flags": [], + }, + { + "task_id": "task-tests", + "title": "Add fixture tests", + "summary": "Add deterministic tests for the bounded helper.", + "route": "agent-work", + "worker_profile": "codex", + "priority": "normal", + "owned_paths": ["tests/test_fixture_helper.py"], + "touched_path_candidates": ["tests/test_fixture_helper.py"], + "acceptance_contract": ["Tests cover the helper behavior.", "Targeted pytest passes."], + "validation_commands": ["python3 -m json.tool workspace/runs/parallel-delivery/taskstream-fixture/work-packages/task-tests/story.json"], + "evidence_files": ["workspace/runs/parallel-delivery/taskstream-fixture/evidence/task-tests-validation.txt"], + "handoff_notes": "Test-only task. Preserve unrelated hunks.", + "risk_flags": [], + }, + { + "task_id": "task-evidence-only", + "title": "Collect handoff evidence", + "summary": "Write and review fixture taskstream handoff evidence.", + "route": "manifest-only", + "worker_profile": "docs-evidence-writer", + "priority": "normal", + "owned_paths": ["workspace/runs/parallel-delivery/taskstream-fixture/evidence"], + "touched_path_candidates": ["workspace/runs/parallel-delivery/taskstream-fixture/evidence/task-evidence-only-validation.txt"], + "acceptance_contract": ["Evidence path is declared.", "No live Taskstream mutation is required."], + "validation_commands": [], + "evidence_files": ["workspace/runs/parallel-delivery/taskstream-fixture/evidence/task-evidence-only-validation.txt"], + "handoff_notes": "Evidence-only task. Keep as manifest-only unless explicitly promoted.", + "risk_flags": ["manifest-only"], + }, + ], + } + write_json(split_plan_path, payload) + (input_dir / "README.md").write_text( + "# Taskstream Fixture Input\n\nDeterministic Patch Swarm split plan for local agent-work handoff validation.\n", + encoding="utf-8", + ) + return split_plan_path diff --git a/scripts/parallel_delivery_validation_e2e.py b/scripts/parallel_delivery_validation_e2e.py new file mode 100644 index 0000000..4f8a874 --- /dev/null +++ b/scripts/parallel_delivery_validation_e2e.py @@ -0,0 +1,1715 @@ +#!/usr/bin/env python3 +"""Deterministic Patch Swarm validation and fixture E2E. + +This module composes the local Patch Swarm planner, path lease, and Codex +packet helpers into a product-quality fixture E2E. It writes artifacts only +under the requested run directory. It does not call live AI services, dispatch +agents, mutate Taskstream/Redmine, or apply patches. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import sys +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +try: + import parallel_delivery_codex_packets as packet_tool + import parallel_delivery_leases as lease_tool + import parallel_delivery_planner as planner_tool +except ImportError: # pragma: no cover - direct script fallback + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import parallel_delivery_codex_packets as packet_tool + import parallel_delivery_leases as lease_tool + import parallel_delivery_planner as planner_tool + + +ROOT = Path(__file__).resolve().parents[1] +RUNS_ROOT = ROOT / "workspace" / "runs" / "parallel-delivery" +DEFAULT_RUN_ROOT = RUNS_ROOT / "e2e-fixture" +CURRENT_SCHEMA_VERSION = 1 +MAX_CANDIDATE_TASKS = 100 +PRODUCER = "cento.parallel-delivery.validation-e2e" + +E2E_STAGES = [ + "request", + "split", + "leases", + "worker_packets", + "fixture_patch_bundles", + "patch_validation", + "malformed_artifact_validation", + "integration_plan", + "dry_run_integration", + "release_candidate", + "validation_summary", +] + +VALIDATION_OVERALL_STATES = {"passed", "failed", "partial"} +PATCH_BUNDLE_STATES = {"accepted", "rejected"} +INTEGRATION_STRATEGIES = {"dry-run-sequential", "dependency-order"} + +REPORT_SECTIONS = [ + "## Summary", + "## Fixture Configuration", + "## Artifact Checks", + "## Lease Checks", + "## Worker Packet Checks", + "## Patch Bundle Checks", + "## Unsafe Bundle Rejection", + "## Integration Plan", + "## Conflict Triage", + "## Dry-Run Integration Receipt", + "## Release Candidate", + "## Evidence", + "## Command Logs", + "## Result", +] + +REQUIRED_RUN_FILES = [ + "request.md", + "run.json", + "context-pack.json", + "split-plan.json", + "task-graph.json", + "path-leases.json", + "worker-packets/codex-packet-bundle.json", + "worker-packets/codex-packet-index.json", + "validation/lease-validation.json", + "validation/packet-validation.json", + "validation/patch-bundle-validation.json", + "validation/malformed-artifact-validation.json", + "integration/integration-plan.json", + "integration/conflict-report.md", + "integration/integration-receipt.json", + "integration/rejected-patches.json", + "integration/dry-run-apply-log.jsonl", + "release-candidate/release-candidate.json", + "release-candidate/release-notes.md", + "release-candidate/demo-evidence.md", + "command-output.log", + "start-here.md", +] + + +class ValidationE2EError(Exception): + """Raised when deterministic validation or fixture E2E fails.""" + + +@dataclass(frozen=True) +class E2ERequest: + run_id: str + run_root: Path + candidate_target: int + max_parallel_agents: int + fixture: bool + dry_run: bool + fixed_timestamp: str | None = None + include_unsafe_fixture: bool = True + objective: str = "" + command: str = "parallel-delivery patch-swarm e2e" + + +@dataclass(frozen=True) +class ValidationCheck: + name: str + ok: bool + category: str + artifact: str | None = None + errors: list[str] | None = None + warnings: list[str] | None = None + + +@dataclass(frozen=True) +class E2EResult: + ok: bool + run_id: str + run_dir: Path + candidate_target: int + candidate_count: int + max_parallel_agents: int + accepted_patch_bundles: int + rejected_patch_bundles: int + overall: str + artifacts: list[str] + warnings: list[str] + errors: list[str] + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json_dumps(payload: Any) -> str: + """Return deterministic JSON with sorted keys, two-space indent, and trailing newline.""" + return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + """Write deterministic JSON artifact.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json_dumps(payload), encoding="utf-8") + + +def read_json(path: Path) -> dict[str, Any]: + """Read JSON and fail clearly.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ValidationE2EError(f"file not found: {rel(path)}") from exc + except json.JSONDecodeError as exc: + raise ValidationE2EError(f"invalid JSON in {rel(path)}: {exc}") from exc + if not isinstance(payload, dict): + raise ValidationE2EError(f"expected JSON object in {rel(path)}") + return payload + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def resolve_path(path: Path) -> Path: + return path if path.is_absolute() else ROOT / path + + +def validate_candidate_target(value: int) -> int: + """Require 1 <= value <= 100.""" + if not isinstance(value, int): + raise ValidationE2EError("candidate_target must be an integer") + if not 1 <= value <= MAX_CANDIDATE_TASKS: + raise ValidationE2EError("candidate_target must be between 1 and 100") + return value + + +def validate_max_parallel_agents(value: int, candidate_target: int) -> int: + """Require 1 <= value <= candidate_target.""" + if not isinstance(value, int): + raise ValidationE2EError("max_parallel_agents must be an integer") + if not 1 <= value <= candidate_target: + raise ValidationE2EError("max_parallel_agents must be between 1 and candidate_target") + return value + + +def e2e_run_dir(run_root: Path, run_id: str) -> Path: + """Return run_root/run_id.""" + return resolve_path(run_root) / run_id + + +def check_dict(check: ValidationCheck) -> dict[str, Any]: + payload = asdict(check) + payload["errors"] = check.errors or [] + payload["warnings"] = check.warnings or [] + return payload + + +def provenance(command: str, source: str = "fixture") -> dict[str, Any]: + return { + "producer": PRODUCER, + "command": command, + "source": source, + "notes": [], + } + + +def _timestamp(request: E2ERequest) -> str: + return request.fixed_timestamp or utc_now() + + +def _digest(*parts: str) -> str: + return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:12] + + +def _safe_run_id(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "-" for ch in value).strip("-._") + if not cleaned: + raise ValidationE2EError("run_id is required") + return cleaned + + +def _reset_generated_run_dir(run_dir: Path) -> None: + run_dir.mkdir(parents=True, exist_ok=True) + generated_dirs = [ + "task-contracts", + "proreq", + "worker-packets", + "fixture-workers", + "patch-bundles", + "validation", + "integration", + "release-candidate", + ] + generated_files = [ + "request.md", + "run.json", + "context-pack.json", + "split-plan.json", + "task-graph.json", + "path-leases.json", + "codex-packet-bundle.json", + "codex-packet-index.json", + "codex-packet-index.md", + "packet-validation.json", + "packet-validation-report.md", + "planner-report.md", + "validation-summary.json", + "validation-report.md", + "command-output.log", + "start-here.md", + ] + for item in generated_dirs: + target = run_dir / item + if target.exists(): + shutil.rmtree(target) + for item in generated_files: + target = run_dir / item + if target.exists(): + target.unlink() + + +def _task_ids(split_plan: dict[str, Any]) -> list[str]: + return [str(task.get("task_id")) for task in split_plan.get("tasks", []) if isinstance(task, dict) and task.get("task_id")] + + +def _tasks_by_id(split_plan: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {str(task.get("task_id")): task for task in split_plan.get("tasks", []) if isinstance(task, dict) and task.get("task_id")} + + +def _leases_by_task(path_leases: dict[str, Any]) -> dict[str, dict[str, Any]]: + leases: dict[str, dict[str, Any]] = {} + for lease in path_leases.get("leases", []): + if isinstance(lease, dict) and lease.get("task_id"): + leases[str(lease["task_id"])] = lease + return leases + + +def path_is_owned(path: str, owned_paths: list[str]) -> bool: + value = path.rstrip("/") + for owned in owned_paths: + root = str(owned).rstrip("/") + if value == root or value.startswith(root + "/"): + return True + return False + + +def path_overlaps(left: str, right: str) -> bool: + left = left.rstrip("/") + right = right.rstrip("/") + return left == right or left.startswith(right + "/") or right.startswith(left + "/") + + +def write_fixture_request(run_dir: Path, request: E2ERequest) -> Path: + """Write deterministic request.md.""" + path = run_dir / "request.md" + text = ( + "# Patch Swarm E2E Fixture Request\n\n" + "Create a deterministic local-first fixture proving request splitting, path leasing, " + "worker packets, patch bundle validation, dry-run integration, and release candidate evidence.\n" + ) + path.write_text(text, encoding="utf-8") + return path + + +def create_fixture_split_plan(run_dir: Path, request: E2ERequest) -> tuple[dict[str, Any], dict[str, Any]]: + """Create deterministic split-plan/task-graph generation through the planner helper.""" + payload, code = planner_tool.run_planner_command( + candidate_target=request.candidate_target, + command=request.command, + dry_run=True, + live_pro=False, + max_parallel_agents=request.max_parallel_agents, + mode="fixture", + request_text=request.objective + or ( + "Create a deterministic local-first fixture proving request splitting, path leasing, " + "worker packets, patch bundle validation, dry-run integration, and release candidate evidence." + ), + run_dir=run_dir, + run_id=request.run_id, + timestamp=_timestamp(request), + ) + if code != 0 or not payload.get("ok"): + raise ValidationE2EError("; ".join(payload.get("errors", ["planner fixture failed"]))) + write_fixture_request(run_dir, request) + return read_json(run_dir / "split-plan.json"), read_json(run_dir / "task-graph.json") + + +def write_run_artifacts(run_dir: Path, request: E2ERequest, split_plan: dict[str, Any], task_graph: dict[str, Any]) -> None: + timestamp = _timestamp(request) + artifact_paths = { + "request": "request.md", + "context_pack": "context-pack.json", + "split_plan": "split-plan.json", + "task_graph": "task-graph.json", + "path_leases": "path-leases.json", + "worker_packets": "worker-packets/codex-packet-index.json", + "validation_summary": "validation-summary.json", + "validation_report": "validation-report.md", + } + run_payload = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "run", + "run_id": request.run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": provenance(request.command), + "request_title": "Patch Swarm E2E Fixture Request", + "state": "run_created", + "fixture": True, + "dry_run": True, + "candidate_target": request.candidate_target, + "candidate_count": len(split_plan.get("tasks", [])), + "max_parallel_agents": request.max_parallel_agents, + "artifact_paths": artifact_paths, + "counts": { + "candidate_tasks": len(split_plan.get("tasks", [])), + "task_graph_nodes": len(task_graph.get("nodes", [])), + }, + "evidence_pointers": [], + } + context_pack = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "context-pack", + "run_id": request.run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": provenance(request.command), + "request_ref": "request.md", + "repo_context": { + "repo": "cento", + "root": "/home/alice/projects/cento", + "local_only": True, + }, + "source_refs": { + "split_plan": "split-plan.json", + "task_graph": "task-graph.json", + "helpers": [ + "scripts/parallel_delivery_planner.py", + "scripts/parallel_delivery_leases.py", + "scripts/parallel_delivery_codex_packets.py", + ], + }, + "constraints": [ + "No live Pro, OpenAI API, Codex dispatch, MCP mutation, Taskstream/Redmine direct writes, or patch application.", + "Fixture workers are simulated local artifact writers.", + "Dry-run integration records only what would be integrated.", + ], + "evidence_pointers": [], + } + write_json(run_dir / "run.json", run_payload) + write_json(run_dir / "context-pack.json", context_pack) + + +def create_fixture_leases( + run_dir: Path, + split_plan: dict[str, Any], + task_graph: dict[str, Any], + request: E2ERequest, +) -> dict[str, Any]: + """Create path-leases generation through the lease helper.""" + path_leases = lease_tool.create_leases( + split_plan, + task_graph, + git_status_text="", + timestamp=_timestamp(request), + command=request.command, + ) + write_json(run_dir / "path-leases.json", path_leases) + write_json(run_dir / "validation" / "lease-validation.json", validate_lease_payload(run_dir, path_leases)) + return path_leases + + +def create_fixture_worker_packets( + run_dir: Path, + split_plan: dict[str, Any], + task_graph: dict[str, Any], + path_leases: dict[str, Any], + request: E2ERequest, +) -> dict[str, Any]: + """Create Codex worker packets through the packet helper.""" + del split_plan, task_graph, path_leases + result = packet_tool.write_packet_bundle( + packet_tool.CodexPacketRequest( + run_id=request.run_id, + run_dir=run_dir, + count=request.candidate_target, + out_dir=run_dir / "worker-packets" / "packets", + fixed_timestamp=_timestamp(request), + ) + ) + worker_dir = run_dir / "worker-packets" + worker_dir.mkdir(parents=True, exist_ok=True) + for name in [ + "codex-packet-bundle.json", + "codex-packet-index.json", + "codex-packet-index.md", + ]: + source = run_dir / name + if source.exists(): + shutil.copyfile(source, worker_dir / name) + packet_validation = packet_tool.validate_packet_bundle(run_dir) + write_json(run_dir / "validation" / "packet-validation.json", packet_validation) + return { + "result": result, + "validation": packet_validation, + "bundle": read_json(worker_dir / "codex-packet-bundle.json"), + "index": read_json(worker_dir / "codex-packet-index.json"), + } + + +def create_simulated_worker_batches( + task_ids: list[str], + max_parallel_agents: int, + task_graph: dict[str, Any], +) -> list[dict[str, Any]]: + """Create deterministic bounded worker batches from the task graph order.""" + ordered = [str(item) for item in task_graph.get("topological_order", []) if str(item) in set(task_ids)] + if set(ordered) != set(task_ids): + ordered = task_ids + batches: list[dict[str, Any]] = [] + for index in range(0, len(ordered), max_parallel_agents): + batches.append( + { + "batch_id": f"batch-{len(batches) + 1:04d}", + "max_parallel_agents": max_parallel_agents, + "task_ids": ordered[index : index + max_parallel_agents], + } + ) + return batches + + +def _diff_for_path(changed_path: str, task_id: str) -> str: + return "\n".join( + [ + "--- /dev/null", + f"+++ b/{changed_path}", + "@@ -0,0 +1,3 @@", + f"+task_id={task_id}", + "+status=fixture", + "+validated=true", + "", + ] + ) + + +def create_fixture_patch_bundles( + run_dir: Path, + split_plan: dict[str, Any], + path_leases: dict[str, Any], + request: E2ERequest, +) -> dict[str, Any]: + """Write valid fixture patch bundles plus one unsafe out-of-lease bundle.""" + patch_dir = run_dir / "patch-bundles" + worker_dir = run_dir / "fixture-workers" + patch_dir.mkdir(parents=True, exist_ok=True) + worker_dir.mkdir(parents=True, exist_ok=True) + leases = _leases_by_task(path_leases) + ledger_path = worker_dir / "simulated-worker-ledger.jsonl" + task_entries: list[dict[str, Any]] = [] + with ledger_path.open("w", encoding="utf-8") as ledger: + for task in split_plan.get("tasks", []): + if not isinstance(task, dict): + continue + task_id = str(task.get("task_id") or "") + lease = leases[task_id] + owned_root = str(lease["owned_paths"][0]).rstrip("/") + changed_path = f"{owned_root}/output.txt" + diff_path = patch_dir / f"{task_id}.diff" + bundle_path = patch_dir / f"{task_id}.patch-bundle.json" + handoff_path = worker_dir / f"{task_id}-handoff.md" + handoff_path.write_text( + f"# Fixture Worker Handoff: {task_id}\n\n" + "- State: simulated fixture worker completed.\n" + f"- Changed path: `{changed_path}`.\n" + "- No repository files were edited.\n", + encoding="utf-8", + ) + diff_path.write_text(_diff_for_path(changed_path, task_id), encoding="utf-8") + bundle = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "patch-bundle", + "run_id": request.run_id, + "created_at": _timestamp(request), + "provenance": provenance("fixture-worker", "simulated-worker"), + "task_id": task_id, + "bundle_id": f"bundle-{task_id}-{_digest(request.run_id, task_id, changed_path)}", + "base_ref": "fixture-base", + "worker_id": "fixture-worker", + "claimed_paths": list(lease.get("owned_paths", [])), + "changed_paths": [changed_path], + "diff_path": f"patch-bundles/{task_id}.diff", + "summary": f"Fixture patch bundle for {task_id}", + "tests_run": [ + { + "command": "fixture bundle safety validation", + "status": "passed", + } + ], + "evidence_files": [f"fixture-workers/{task_id}-handoff.md"], + "handoff_note": f"fixture-workers/{task_id}-handoff.md", + "risks": [], + "requires_manual_review": False, + "evidence_pointers": [f"fixture-workers/{task_id}-handoff.md"], + } + write_json(bundle_path, bundle) + task_entries.append(bundle) + ledger.write(stable_json_dumps({"event": "patch_bundle_written", "task_id": task_id, "bundle_id": bundle["bundle_id"]}).strip() + "\n") + + if request.include_unsafe_fixture: + unsafe_task = task_entries[0]["task_id"] if task_entries else "task-0001" + unsafe_diff = patch_dir / "unsafe-out-of-lease.diff" + unsafe_bundle_path = patch_dir / "unsafe-out-of-lease.patch-bundle.json" + unsafe_diff.write_text(_diff_for_path("README.md", unsafe_task), encoding="utf-8") + unsafe_bundle = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "patch-bundle", + "run_id": request.run_id, + "created_at": _timestamp(request), + "provenance": provenance("fixture-worker", "unsafe-negative-fixture"), + "task_id": unsafe_task, + "bundle_id": "unsafe-out-of-lease", + "base_ref": "fixture-base", + "worker_id": "fixture-worker-unsafe", + "claimed_paths": ["README.md"], + "changed_paths": ["README.md"], + "diff_path": "patch-bundles/unsafe-out-of-lease.diff", + "summary": "Intentional unsafe out-of-lease fixture bundle.", + "tests_run": [], + "evidence_files": [], + "handoff_note": "", + "risks": ["changed path outside owned lease"], + "requires_manual_review": True, + "evidence_pointers": [], + } + write_json(unsafe_bundle_path, unsafe_bundle) + ledger.write(stable_json_dumps({"event": "unsafe_patch_bundle_written", "task_id": unsafe_task, "bundle_id": "unsafe-out-of-lease"}).strip() + "\n") + return {"bundles": task_entries, "ledger": rel(ledger_path)} + + +def validate_artifacts(run_dir: Path) -> list[ValidationCheck]: + """Validate required artifacts exist and parse.""" + checks: list[ValidationCheck] = [] + for name in REQUIRED_RUN_FILES: + path = run_dir / name + checks.append( + ValidationCheck( + name=f"artifact_exists:{name}", + ok=path.exists(), + category="artifact", + artifact=name, + errors=[] if path.exists() else [f"missing required artifact: {name}"], + ) + ) + if path.suffix == ".json" and path.exists(): + try: + read_json(path) + except ValidationE2EError as exc: + checks.append( + ValidationCheck( + name=f"json_parse:{name}", + ok=False, + category="artifact", + artifact=name, + errors=[str(exc)], + ) + ) + return checks + + +def validate_lease_payload(run_dir: Path, path_leases: dict[str, Any] | None = None) -> dict[str, Any]: + path_leases = path_leases or read_json(run_dir / "path-leases.json") + errors = lease_tool.validate_path_leases(path_leases) + checks = [ + check_dict( + ValidationCheck( + name="path-leases-schema-and-safety", + ok=not errors, + category="lease", + artifact="path-leases.json", + errors=errors, + ) + ) + ] + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "lease-validation", + "run_id": path_leases.get("run_id"), + "ok": not errors, + "checks": checks, + "errors": errors, + "warnings": [], + } + + +def validate_leases(run_dir: Path) -> list[ValidationCheck]: + """Validate no overlap and every task has a lease.""" + split_plan = read_json(run_dir / "split-plan.json") + path_leases = read_json(run_dir / "path-leases.json") + task_ids = set(_task_ids(split_plan)) + leases = [lease for lease in path_leases.get("leases", []) if isinstance(lease, dict)] + leased_tasks = {str(lease.get("task_id")) for lease in leases} + errors = lease_tool.validate_path_leases(path_leases) + if leased_tasks != task_ids: + errors.append("leased task IDs must match split-plan task IDs") + owned: list[tuple[str, str]] = [] + for lease in leases: + for path in lease.get("owned_paths", []): + owned.append((str(lease.get("task_id")), str(path).rstrip("/"))) + for index, (task_a, path_a) in enumerate(owned): + for task_b, path_b in owned[index + 1 :]: + if path_overlaps(path_a, path_b): + errors.append(f"owned path overlap: {task_a}:{path_a} and {task_b}:{path_b}") + return [ + ValidationCheck( + name="leases-cover-tasks-and-do-not-overlap", + ok=not errors, + category="lease", + artifact="path-leases.json", + errors=errors, + ) + ] + + +def validate_worker_packets(run_dir: Path) -> list[ValidationCheck]: + """Validate one packet per task and required packet sections.""" + split_plan = read_json(run_dir / "split-plan.json") + task_ids = set(_task_ids(split_plan)) + index = read_json(run_dir / "worker-packets" / "codex-packet-index.json") + packets = [item for item in index.get("packets", []) if isinstance(item, dict)] + packet_tasks = {str(item.get("task_id")) for item in packets} + errors: list[str] = [] + if packet_tasks != task_ids: + errors.append("worker packet task IDs must match split-plan task IDs") + validation = packet_tool.validate_packet_bundle(run_dir) + errors.extend(str(item) for item in validation.get("errors", [])) + return [ + ValidationCheck( + name="worker-packets-cover-tasks", + ok=not errors, + category="packet", + artifact="worker-packets/codex-packet-index.json", + errors=errors, + warnings=[str(item) for item in validation.get("warnings", [])], + ) + ] + + +def _is_protected_path(path: str) -> bool: + lowered = path.lower() + protected_fragments = [".env", ".git/", ".pem", ".key", "secret", "token", "credential"] + return any(fragment in lowered for fragment in protected_fragments) + + +def _validate_bundle_payload( + run_dir: Path, + bundle_path: Path, + bundle: dict[str, Any], + tasks_by_id: dict[str, dict[str, Any]], + leases_by_task: dict[str, dict[str, Any]], +) -> tuple[dict[str, Any], bool]: + errors: list[str] = [] + required = [ + "schema_version", + "artifact_type", + "run_id", + "task_id", + "bundle_id", + "base_ref", + "worker_id", + "claimed_paths", + "changed_paths", + "diff_path", + "summary", + "tests_run", + "evidence_files", + "requires_manual_review", + ] + for field in required: + if field not in bundle: + errors.append(f"missing required field: {field}") + task_id = str(bundle.get("task_id") or "") + if task_id not in tasks_by_id: + errors.append(f"unknown task_id: {task_id}") + lease = leases_by_task.get(task_id) + if not lease: + errors.append(f"no active lease for task {task_id}") + changed_paths = bundle.get("changed_paths") + if not isinstance(changed_paths, list) or not changed_paths: + errors.append("changed_paths must be a non-empty list") + changed_paths = [] + claimed_paths = bundle.get("claimed_paths") + if not isinstance(claimed_paths, list): + errors.append("claimed_paths must be a list") + tests_run = bundle.get("tests_run") + if not isinstance(tests_run, list) or not tests_run: + errors.append("tests_run must include fixture validation evidence") + evidence_files = bundle.get("evidence_files") + if not isinstance(evidence_files, list) or not evidence_files: + errors.append("evidence_files must include at least one existing evidence file") + evidence_files = [] + for item in evidence_files: + if not (run_dir / str(item)).exists(): + errors.append(f"evidence file missing: {item}") + diff_path = str(bundle.get("diff_path") or "") + if diff_path and not (run_dir / diff_path).exists(): + errors.append(f"diff_path missing: {diff_path}") + if lease: + owned_paths = [str(path) for path in lease.get("owned_paths", [])] + for changed in changed_paths: + path = str(changed) + if path.endswith(".png") or path.endswith(".jpg") or path.endswith(".gif"): + errors.append(f"binary-like patch path rejected: {path}") + if _is_protected_path(path): + errors.append(f"protected path rejected: {path}") + if not path_is_owned(path, owned_paths): + errors.append(f"changed path outside owned lease: {path}") + for field in ("deleted_paths", "renames"): + if bundle.get(field): + errors.append(f"{field} are not allowed in fixture patch bundles") + accepted = not errors + return ( + { + "bundle_id": str(bundle.get("bundle_id") or bundle_path.stem), + "task_id": task_id, + "path": rel(bundle_path), + "state": "accepted" if accepted else "rejected", + "changed_paths": changed_paths, + "claimed_paths": claimed_paths if isinstance(claimed_paths, list) else [], + "errors": errors, + }, + accepted, + ) + + +def validate_patch_bundles(run_dir: Path) -> tuple[list[ValidationCheck], list[dict[str, Any]], list[dict[str, Any]]]: + """Validate patch bundles; return checks, accepted bundles, rejected bundles.""" + split_plan = read_json(run_dir / "split-plan.json") + path_leases = read_json(run_dir / "path-leases.json") + tasks_by_id = _tasks_by_id(split_plan) + leases_by_task = _leases_by_task(path_leases) + checks: list[ValidationCheck] = [] + accepted: list[dict[str, Any]] = [] + rejected: list[dict[str, Any]] = [] + bundle_results: list[dict[str, Any]] = [] + for bundle_path in sorted((run_dir / "patch-bundles").glob("*.patch-bundle.json")): + try: + bundle = read_json(bundle_path) + result, ok = _validate_bundle_payload(run_dir, bundle_path, bundle, tasks_by_id, leases_by_task) + except ValidationE2EError as exc: + result = { + "bundle_id": bundle_path.stem, + "task_id": "", + "path": rel(bundle_path), + "state": "rejected", + "changed_paths": [], + "claimed_paths": [], + "errors": [str(exc)], + } + ok = False + bundle_results.append(result) + if ok: + accepted.append(result) + else: + rejected.append(result) + checks.append( + ValidationCheck( + name=f"patch-bundle:{result['bundle_id']}", + ok=ok or result["bundle_id"] == "unsafe-out-of-lease", + category="patch-bundle" if ok else "negative", + artifact=result["path"], + errors=[] if ok or result["bundle_id"] == "unsafe-out-of-lease" else result["errors"], + warnings=[], + ) + ) + validation = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "patch-bundle-validation", + "run_id": split_plan.get("run_id"), + "ok": bool(accepted) and any(item.get("bundle_id") == "unsafe-out-of-lease" for item in rejected), + "accepted": accepted, + "rejected": rejected, + "checks": [check_dict(check) for check in checks], + "errors": [], + "warnings": [], + } + write_json(run_dir / "validation" / "patch-bundle-validation.json", validation) + write_json( + run_dir / "integration" / "rejected-patches.json", + { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "rejected-patches", + "run_id": split_plan.get("run_id"), + "rejected": rejected, + }, + ) + return checks, accepted, rejected + + +def validate_malformed_artifact_rejection(run_dir: Path) -> list[ValidationCheck]: + """Write malformed artifact and prove validation rejects it.""" + malformed_dir = run_dir / "validation" / "malformed" + malformed_dir.mkdir(parents=True, exist_ok=True) + malformed_path = malformed_dir / "missing-run-id.json" + write_json( + malformed_path, + { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "patch-bundle", + "task_id": "task-0001", + "bundle_id": "malformed-missing-run-id", + }, + ) + payload = read_json(malformed_path) + errors = [] + if not payload.get("run_id"): + errors.append("missing required field: run_id") + result = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "malformed-artifact-validation", + "run_id": read_json(run_dir / "split-plan.json").get("run_id"), + "ok": bool(errors), + "negative_check": "malformed artifact is rejected clearly", + "malformed_artifact": "validation/malformed/missing-run-id.json", + "state": "rejected" if errors else "accepted", + "errors": errors, + "warnings": [], + } + write_json(run_dir / "validation" / "malformed-artifact-validation.json", result) + return [ + ValidationCheck( + name="malformed-artifact:missing-run-id", + ok=bool(errors), + category="negative", + artifact="validation/malformed/missing-run-id.json", + errors=[] if errors else ["malformed artifact was not rejected"], + warnings=[], + ) + ] + + +def paths_overlap(left: str, right: str) -> bool: + """Return True when two normalized repo-relative paths overlap.""" + left_clean = str(left).strip().rstrip("/") + right_clean = str(right).strip().rstrip("/") + return bool(left_clean and right_clean) and ( + left_clean == right_clean + or left_clean.startswith(right_clean + "/") + or right_clean.startswith(left_clean + "/") + ) + + +def integration_conflict_triage( + accepted_bundles: list[dict[str, Any]], + rejected_bundles: list[dict[str, Any]], + task_graph: dict[str, Any], +) -> dict[str, Any]: + """Build deterministic integration buckets without applying patches.""" + dependency_order = [str(item) for item in task_graph.get("topological_order", []) if str(item)] + accepted_by_task = {str(item.get("task_id") or ""): item for item in accepted_bundles} + ordered = [accepted_by_task[task_id] for task_id in dependency_order if task_id in accepted_by_task] + path_owners: dict[str, list[str]] = {} + conflicts: list[dict[str, Any]] = [] + conflict_bundle_ids: set[str] = set() + for index, bundle in enumerate(ordered): + bundle_id = str(bundle.get("bundle_id") or "") + changed_paths = [str(path).strip().rstrip("/") for path in bundle.get("changed_paths", []) if str(path).strip()] + for path in changed_paths: + path_owners.setdefault(path, []).append(bundle_id) + for previous in ordered[:index]: + previous_id = str(previous.get("bundle_id") or "") + previous_paths = [str(value).strip().rstrip("/") for value in previous.get("changed_paths", []) if str(value).strip()] + for path in changed_paths: + for previous_path in previous_paths: + if paths_overlap(path, previous_path): + conflict = { + "type": "same_path", + "path": path, + "other_path": previous_path, + "bundle_ids": sorted([previous_id, bundle_id]), + "task_ids": sorted([str(previous.get("task_id") or ""), str(bundle.get("task_id") or "")]), + "bucket": "needs_human_review", + "reason": "same or overlapping changed path requires serialized review before apply", + } + if conflict not in conflicts: + conflicts.append(conflict) + conflict_bundle_ids.update([previous_id, bundle_id]) + + safe_apply: list[dict[str, Any]] = [] + needs_human_review: list[dict[str, Any]] = [] + for order, bundle in enumerate(ordered, start=1): + row = { + "order": order, + "task_id": bundle.get("task_id"), + "bundle_id": bundle.get("bundle_id"), + "patch_bundle": bundle.get("path"), + "changed_paths": bundle.get("changed_paths", []), + } + if str(bundle.get("bundle_id") or "") in conflict_bundle_ids: + needs_human_review.append({**row, "reason": "same-path conflict"}) + else: + safe_apply.append(row) + + reject = [ + { + "task_id": item.get("task_id"), + "bundle_id": item.get("bundle_id"), + "patch_bundle": item.get("path"), + "changed_paths": item.get("changed_paths", []), + "errors": item.get("errors", []), + "reason": "bundle rejected by patch-bundle validation", + } + for item in rejected_bundles + ] + buckets = { + "safe_apply": safe_apply, + "needs_rebase": [], + "needs_human_review": needs_human_review, + "reject": reject, + } + return { + "dependency_order": dependency_order, + "path_owners": {path: sorted(set(bundle_ids)) for path, bundle_ids in sorted(path_owners.items())}, + "conflicts": conflicts, + "conflict_count": len(conflicts), + "buckets": buckets, + "bucket_counts": {name: len(items) for name, items in buckets.items()}, + "rollback_metadata": { + "strategy": "dry_run_no_changes", + "apply_mode": "dry-run", + "source_mutation": "none", + "rollback_action": "discard fixture evidence or skip conflicted bundle before Safe Integrator handoff", + }, + } + + +def write_conflict_report(run_dir: Path, plan: dict[str, Any]) -> Path: + """Write a human-readable conflict triage report.""" + path = run_dir / "integration" / "conflict-report.md" + buckets = plan.get("buckets", {}) + conflicts = plan.get("conflicts", []) + lines = [ + "# Patch Swarm Integration Conflict Report", + "", + f"- Run ID: `{plan.get('run_id')}`", + f"- Strategy: `{plan.get('strategy')}`", + f"- Conflict count: `{len(conflicts)}`", + "", + "## Dependency Order", + "", + *[f"- `{task_id}`" for task_id in plan.get("dependency_order", [])], + "", + "## Safe Apply", + "", + *( + [ + f"- order `{item.get('order')}` bundle `{item.get('bundle_id')}` task `{item.get('task_id')}`" + for item in buckets.get("safe_apply", []) + ] + or ["- None"] + ), + "", + "## Needs Rebase", + "", + *([f"- bundle `{item.get('bundle_id')}`: {item.get('reason', '')}" for item in buckets.get("needs_rebase", [])] or ["- None"]), + "", + "## Needs Human Review", + "", + *( + [ + f"- bundle `{item.get('bundle_id')}` task `{item.get('task_id')}`: {item.get('reason', '')}" + for item in buckets.get("needs_human_review", []) + ] + or ["- None"] + ), + "", + "## Rejected", + "", + *( + [ + f"- bundle `{item.get('bundle_id')}` task `{item.get('task_id')}`: {', '.join(item.get('errors', []) or [item.get('reason', '')])}" + for item in buckets.get("reject", []) + ] + or ["- None"] + ), + "", + "## Conflict Details", + "", + *( + [ + f"- `{item.get('type')}` `{item.get('path')}` vs `{item.get('other_path')}` bundles `{', '.join(item.get('bundle_ids', []))}`" + for item in conflicts + ] + or ["- No same-path conflicts detected."] + ), + "", + "## Rollback", + "", + f"- Strategy: `{plan.get('rollback_metadata', {}).get('strategy', '')}`", + f"- Source mutation: `{plan.get('rollback_metadata', {}).get('source_mutation', '')}`", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def create_integration_plan( + run_dir: Path, + accepted_bundles: list[dict[str, Any]], + rejected_bundles: list[dict[str, Any]], + task_graph: dict[str, Any], +) -> dict[str, Any]: + """Create deterministic integration-plan.json.""" + triage = integration_conflict_triage(accepted_bundles, rejected_bundles, task_graph) + accepted_by_task = {str(item["task_id"]): item for item in triage["buckets"]["safe_apply"]} + queue = [] + for task_id in task_graph.get("topological_order", []): + bundle = accepted_by_task.get(str(task_id)) + if not bundle: + continue + queue.append( + { + "order": len(queue) + 1, + "task_id": bundle["task_id"], + "bundle_id": bundle["bundle_id"], + "patch_bundle": bundle.get("patch_bundle") or bundle.get("path"), + "changed_paths": bundle.get("changed_paths", []), + } + ) + plan = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "integration-plan", + "run_id": task_graph.get("run_id"), + "created_at": task_graph.get("created_at"), + "provenance": provenance("patch-swarm e2e", "patch-bundle-validation"), + "strategy": "dry-run-sequential", + "dry_run": True, + "queue": queue, + "rejected": rejected_bundles, + "dependency_order": triage["dependency_order"], + "conflicts": triage["conflicts"], + "conflict_count": triage["conflict_count"], + "buckets": triage["buckets"], + "bucket_counts": triage["bucket_counts"], + "rollback_metadata": triage["rollback_metadata"], + "evidence_pointers": [ + "integration/conflict-report.md", + "integration/integration-receipt.json", + "integration/dry-run-apply-log.jsonl", + ], + } + write_json(run_dir / "integration" / "integration-plan.json", plan) + write_conflict_report(run_dir, plan) + return plan + + +def dry_run_integrate(run_dir: Path, integration_plan: dict[str, Any]) -> dict[str, Any]: + """Create dry-run integration-receipt.json without applying patches.""" + integrated = [] + log_path = run_dir / "integration" / "dry-run-apply-log.jsonl" + with log_path.open("w", encoding="utf-8") as log: + for item in integration_plan.get("queue", []): + event = { + "event": "dry_run_apply", + "order": item.get("order"), + "task_id": item.get("task_id"), + "bundle_id": item.get("bundle_id"), + "state": "would_integrate", + } + log.write(stable_json_dumps(event).strip() + "\n") + integrated.append(event) + receipt = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "integration-receipt", + "run_id": integration_plan.get("run_id"), + "created_at": integration_plan.get("created_at"), + "started_at": integration_plan.get("created_at"), + "completed_at": integration_plan.get("created_at"), + "provenance": provenance("patch-swarm e2e", "integration-plan"), + "strategy": integration_plan.get("strategy", "dry-run-sequential"), + "dry_run": True, + "integrated": integrated, + "rejected": integration_plan.get("rejected", []), + "conflicts": integration_plan.get("conflicts", []), + "bucket_counts": integration_plan.get("bucket_counts", {}), + "rollback_metadata": integration_plan.get("rollback_metadata", {}), + "final_state": "dry_run_completed", + "source_mutation": "none", + "evidence_pointers": ["integration/conflict-report.md", "integration/dry-run-apply-log.jsonl"], + } + write_json(run_dir / "integration" / "integration-receipt.json", receipt) + return receipt + + +def create_release_candidate(run_dir: Path, validation_summary: dict[str, Any], integration_receipt: dict[str, Any]) -> dict[str, Any]: + """Create release-candidate.json and release-notes.md.""" + rc = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "release-candidate", + "run_id": validation_summary.get("run_id"), + "created_at": validation_summary.get("created_at"), + "provenance": provenance("patch-swarm e2e", "dry-run-integration"), + "state": "rc_fixture_validated", + "fixture": True, + "production_release": False, + "validation_summary": "validation-summary.json", + "integration_receipt": "integration/integration-receipt.json", + "integrated_count": len(integration_receipt.get("integrated", [])), + "rejected_count": len(integration_receipt.get("rejected", [])), + "overall": validation_summary.get("overall"), + "evidence_pointers": ["release-candidate/release-notes.md", "release-candidate/demo-evidence.md"], + } + write_json(run_dir / "release-candidate" / "release-candidate.json", rc) + notes = [ + "# Patch Swarm Fixture Release Candidate", + "", + "This is fixture evidence only. It is not a production release and no patches were applied.", + "", + f"- Run ID: `{rc['run_id']}`", + f"- State: `{rc['state']}`", + f"- Integrated dry-run bundles: `{rc['integrated_count']}`", + f"- Rejected bundles: `{rc['rejected_count']}`", + "- Validation summary: `validation-summary.json`", + "- Integration receipt: `integration/integration-receipt.json`", + ] + (run_dir / "release-candidate" / "release-notes.md").write_text("\n".join(notes) + "\n", encoding="utf-8") + demo = [ + "# Patch Swarm Fixture Demo Evidence", + "", + "This local fixture proves the operator console can inspect run artifacts without a database.", + "", + f"- Run ID: `{rc['run_id']}`", + f"- State: `{rc['state']}`", + f"- Integrated dry-run bundles: `{rc['integrated_count']}`", + f"- Rejected bundles: `{rc['rejected_count']}`", + "- Console source artifacts are local files under this run directory.", + ] + (run_dir / "release-candidate" / "demo-evidence.md").write_text("\n".join(demo) + "\n", encoding="utf-8") + return rc + + +def build_validation_summary( + run_dir: Path, + request: E2ERequest, + checks: list[ValidationCheck], + counts: dict[str, Any], + simulated_worker_batches: list[dict[str, Any]], +) -> dict[str, Any]: + """Create validation-summary.json.""" + failed = [check for check in checks if not check.ok] + timestamp = _timestamp(request) + categories = { + "artifact_checks": "artifact", + "lease_checks": "lease", + "packet_checks": "packet", + "patch_bundle_checks": "patch-bundle", + "integration_checks": "integration", + "release_candidate_checks": "release-candidate", + "negative_checks": "negative", + } + summary = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "validation-summary", + "run_id": request.run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": provenance(request.command), + "fixture": True, + "candidate_target": request.candidate_target, + "candidate_count": counts.get("candidate_tasks", 0), + "max_parallel_agents": request.max_parallel_agents, + "simulated_worker_batches": simulated_worker_batches, + "counts": {**counts, "failed_checks": len(failed)}, + "overall": "passed" if not failed else "failed", + "errors": [error for check in failed for error in (check.errors or [])], + "warnings": [warning for check in checks for warning in (check.warnings or [])], + "evidence_pointers": [ + "validation-report.md", + "integration/integration-receipt.json", + "release-candidate/release-candidate.json", + ], + } + check_payloads = [check_dict(check) for check in checks] + for field, category in categories.items(): + summary[field] = [item for item in check_payloads if item["category"] == category] + write_json(run_dir / "validation-summary.json", summary) + return summary + + +def write_validation_report(run_dir: Path, summary: dict[str, Any], checks: list[ValidationCheck]) -> Path: + """Create validation-report.md.""" + path = run_dir / "validation-report.md" + counts = summary.get("counts", {}) + rejected = read_json(run_dir / "integration" / "rejected-patches.json").get("rejected", []) + lines = [ + "# Patch Swarm Validation Report", + "", + "## Summary", + "", + f"- Run ID: `{summary.get('run_id')}`", + f"- Overall: `{summary.get('overall')}`", + f"- Candidate tasks: `{summary.get('candidate_count')}`", + "", + "## Fixture Configuration", + "", + f"- Candidate target: `{summary.get('candidate_target')}`", + f"- Max parallel agents: `{summary.get('max_parallel_agents')}`", + f"- Simulated worker batches: `{len(summary.get('simulated_worker_batches', []))}`", + "", + "## Artifact Checks", + "", + *(f"- `{check.name}`: `{check.ok}`" for check in checks if check.category == "artifact"), + "", + "## Lease Checks", + "", + *(f"- `{check.name}`: `{check.ok}`" for check in checks if check.category == "lease"), + "", + "## Worker Packet Checks", + "", + *(f"- `{check.name}`: `{check.ok}`" for check in checks if check.category == "packet"), + "", + "## Patch Bundle Checks", + "", + f"- Accepted bundles: `{counts.get('accepted_patch_bundles')}`", + f"- Rejected bundles: `{counts.get('rejected_patch_bundles')}`", + "", + "## Unsafe Bundle Rejection", + "", + *(f"- `{item.get('bundle_id')}`: {', '.join(item.get('errors', []))}" for item in rejected), + "", + "## Integration Plan", + "", + f"- Queue length: `{counts.get('integration_queue')}`", + "- Rejected bundles are excluded from `integration/integration-plan.json`.", + "- Conflict buckets are recorded in `integration/conflict-report.md`.", + "", + "## Conflict Triage", + "", + "- Safe apply, needs-rebase, needs-human-review, and rejected buckets are deterministic.", + "- Same-path conflicts block automatic integration and move bundles to human review.", + "", + "## Dry-Run Integration Receipt", + "", + f"- Dry-run integrated: `{counts.get('dry_run_integrated')}`", + "- No repository source files were changed by integration.", + "", + "## Release Candidate", + "", + "- `release-candidate/release-candidate.json`", + "- `release-candidate/release-notes.md`", + "", + "## Evidence", + "", + "- `validation-summary.json`", + "- `validation-report.md`", + "- `integration/conflict-report.md`", + "- `command-output.log`", + "", + "## Command Logs", + "", + "- `command-output.log` records the local fixture stages.", + "", + "## Result", + "", + f"`{summary.get('overall')}`", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def write_start_here(run_dir: Path, summary: dict[str, Any]) -> None: + lines = [ + f"# Patch Swarm Validation E2E: {summary['run_id']}", + "", + "Start with `validation-summary.json`, then read `validation-report.md` for the human summary.", + "", + "Important artifacts:", + "", + "- `split-plan.json`", + "- `path-leases.json`", + "- `worker-packets/codex-packet-index.json`", + "- `validation/patch-bundle-validation.json`", + "- `integration/conflict-report.md`", + "- `integration/integration-receipt.json`", + "- `release-candidate/release-candidate.json`", + ] + (run_dir / "start-here.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _write_command_log(run_dir: Path, request: E2ERequest, stages: list[str]) -> None: + lines = [ + "# Patch Swarm Fixture Command Output", + "", + f"command={request.command}", + f"run_id={request.run_id}", + f"candidate_target={request.candidate_target}", + f"max_parallel_agents={request.max_parallel_agents}", + "live_pro=false", + "dry_run=true", + "", + "## Stages", + "", + ] + lines.extend(f"- {stage}" for stage in stages) + (run_dir / "command-output.log").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def run_fixture_e2e(request: E2ERequest) -> E2EResult: + """Execute deterministic local fixture flow end to end.""" + candidate_target = validate_candidate_target(int(request.candidate_target)) + max_parallel_agents = validate_max_parallel_agents(int(request.max_parallel_agents), candidate_target) + normalized = E2ERequest( + run_id=_safe_run_id(request.run_id), + run_root=request.run_root, + candidate_target=candidate_target, + max_parallel_agents=max_parallel_agents, + fixture=True, + dry_run=True, + fixed_timestamp=request.fixed_timestamp, + include_unsafe_fixture=request.include_unsafe_fixture, + objective=request.objective, + command=request.command, + ) + run_dir = e2e_run_dir(normalized.run_root, normalized.run_id) + _reset_generated_run_dir(run_dir) + timestamp = _timestamp(normalized) + + split_plan, task_graph = create_fixture_split_plan(run_dir, normalized) + write_run_artifacts(run_dir, normalized, split_plan, task_graph) + path_leases = create_fixture_leases(run_dir, split_plan, task_graph, normalized) + worker_packets = create_fixture_worker_packets(run_dir, split_plan, task_graph, path_leases, normalized) + task_ids = _task_ids(split_plan) + batches = create_simulated_worker_batches(task_ids, max_parallel_agents, task_graph) + create_fixture_patch_bundles(run_dir, split_plan, path_leases, normalized) + patch_checks, accepted, rejected = validate_patch_bundles(run_dir) + malformed_checks = validate_malformed_artifact_rejection(run_dir) + integration_plan = create_integration_plan(run_dir, accepted, rejected, task_graph) + integration_receipt = dry_run_integrate(run_dir, integration_plan) + + counts = { + "candidate_tasks": len(task_ids), + "leases": len(path_leases.get("leases", [])), + "worker_packets": int(worker_packets["index"].get("packet_count") or 0), + "fixture_patch_bundles": len(accepted) + len(rejected), + "accepted_patch_bundles": len(accepted), + "rejected_patch_bundles": len(rejected), + "integration_queue": len(integration_plan.get("queue", [])), + "dry_run_integrated": len(integration_receipt.get("integrated", [])), + } + integration_checks = [ + ValidationCheck( + name="integration-plan-excludes-rejected", + ok="unsafe-out-of-lease" not in stable_json_dumps(integration_plan.get("queue", [])), + category="integration", + artifact="integration/integration-plan.json", + errors=[] if "unsafe-out-of-lease" not in stable_json_dumps(integration_plan.get("queue", [])) else ["unsafe bundle was queued"], + ), + ValidationCheck( + name="dry-run-integrated-accepted-only", + ok=len(integration_receipt.get("integrated", [])) == len(accepted), + category="integration", + artifact="integration/integration-receipt.json", + errors=[] if len(integration_receipt.get("integrated", [])) == len(accepted) else ["dry-run integration count mismatch"], + ), + ] + release_placeholder_summary = { + "run_id": normalized.run_id, + "created_at": timestamp, + "overall": "pending", + } + create_release_candidate(run_dir, release_placeholder_summary, integration_receipt) + release_checks = [ + ValidationCheck( + name="release-candidate-evidence-written", + ok=(run_dir / "release-candidate" / "release-candidate.json").exists() + and (run_dir / "release-candidate" / "release-notes.md").exists(), + category="release-candidate", + artifact="release-candidate/release-candidate.json", + errors=[], + ) + ] + _write_command_log(run_dir, normalized, E2E_STAGES) + artifact_validation = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "artifact-validation", + "run_id": normalized.run_id, + "created_at": timestamp, + "ok": True, + "checks": [], + "errors": [], + "warnings": [], + } + write_json(run_dir / "validation" / "artifact-validation.json", artifact_validation) + artifact_checks = validate_artifacts(run_dir) + write_json( + run_dir / "validation" / "artifact-validation.json", + { + **artifact_validation, + "ok": all(check.ok for check in artifact_checks), + "checks": [check_dict(check) for check in artifact_checks], + "errors": [error for check in artifact_checks for error in (check.errors or [])], + }, + ) + checks = [ + *artifact_checks, + *validate_leases(run_dir), + *validate_worker_packets(run_dir), + *patch_checks, + *malformed_checks, + *integration_checks, + *release_checks, + ] + summary = build_validation_summary(run_dir, normalized, checks, counts, batches) + rc = create_release_candidate(run_dir, summary, integration_receipt) + release_checks = [ + ValidationCheck( + name="release-candidate-state", + ok=rc.get("state") == "rc_fixture_validated", + category="release-candidate", + artifact="release-candidate/release-candidate.json", + errors=[] if rc.get("state") == "rc_fixture_validated" else ["release candidate state mismatch"], + ) + ] + checks = [ + *artifact_checks, + *validate_leases(run_dir), + *validate_worker_packets(run_dir), + *patch_checks, + *malformed_checks, + *integration_checks, + *release_checks, + ] + summary = build_validation_summary(run_dir, normalized, checks, counts, batches) + write_validation_report(run_dir, summary, checks) + write_start_here(run_dir, summary) + final = validate_e2e_run(run_dir) + errors = [str(item) for item in final.get("errors", [])] + artifacts = [ + rel(run_dir / "validation-summary.json"), + rel(run_dir / "validation-report.md"), + rel(run_dir / "integration" / "integration-receipt.json"), + rel(run_dir / "release-candidate" / "release-candidate.json"), + ] + return E2EResult( + ok=not errors and summary.get("overall") == "passed", + run_id=normalized.run_id, + run_dir=run_dir, + candidate_target=normalized.candidate_target, + candidate_count=len(task_ids), + max_parallel_agents=normalized.max_parallel_agents, + accepted_patch_bundles=len(accepted), + rejected_patch_bundles=len(rejected), + overall=str(summary.get("overall")), + artifacts=artifacts, + warnings=[str(item) for item in summary.get("warnings", [])], + errors=errors, + ) + + +def validate_e2e_run(run_dir: Path) -> dict[str, Any]: + """Validate an existing E2E run directory and return summary JSON.""" + resolved = resolve_path(run_dir) + errors: list[str] = [] + try: + summary = read_json(resolved / "validation-summary.json") + split_plan = read_json(resolved / "split-plan.json") + path_leases = read_json(resolved / "path-leases.json") + packet_index = read_json(resolved / "worker-packets" / "codex-packet-index.json") + integration_plan = read_json(resolved / "integration" / "integration-plan.json") + integration_receipt = read_json(resolved / "integration" / "integration-receipt.json") + except ValidationE2EError as exc: + return { + "ok": False, + "run_id": resolved.name, + "run_dir": rel(resolved), + "overall": "failed", + "errors": [str(exc)], + "warnings": [], + } + task_ids = set(_task_ids(split_plan)) + lease_tasks = {str(item.get("task_id")) for item in path_leases.get("leases", []) if isinstance(item, dict)} + packet_tasks = {str(item.get("task_id")) for item in packet_index.get("packets", []) if isinstance(item, dict)} + if lease_tasks != task_ids: + errors.append("leases do not cover exactly all tasks") + if packet_tasks != task_ids: + errors.append("worker packets do not cover exactly all tasks") + if len(integration_plan.get("queue", [])) != int(summary.get("counts", {}).get("accepted_patch_bundles") or -1): + errors.append("integration queue count does not match accepted bundle count") + if len(integration_receipt.get("integrated", [])) != int(summary.get("counts", {}).get("accepted_patch_bundles") or -1): + errors.append("dry-run integrated count does not match accepted bundle count") + if "unsafe-out-of-lease" in stable_json_dumps(integration_plan.get("queue", [])): + errors.append("unsafe bundle appears in integration queue") + if summary.get("overall") != "passed": + errors.append("validation-summary overall is not passed") + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "validation-e2e-run-validation", + "ok": not errors, + "run_id": summary.get("run_id", resolved.name), + "run_dir": rel(resolved), + "overall": "passed" if not errors else "failed", + "candidate_count": summary.get("candidate_count", 0), + "counts": summary.get("counts", {}), + "errors": errors, + "warnings": [], + } + + +def print_policy() -> dict[str, Any]: + """Return validation engine policy for tests and CLI.""" + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "validation-e2e-policy", + "producer": PRODUCER, + "local_only": True, + "no_api_calls": True, + "no_live_pro": True, + "no_codex_dispatch": True, + "no_mcp_mutation": True, + "no_taskstream_redmine_writes": True, + "dry_run_integration": True, + "applies_patches": False, + "max_candidate_tasks": MAX_CANDIDATE_TASKS, + "stages": E2E_STAGES, + "validation_overall_states": sorted(VALIDATION_OVERALL_STATES), + "patch_bundle_states": sorted(PATCH_BUNDLE_STATES), + "integration_strategies": sorted(INTEGRATION_STRATEGIES), + } + + +def result_payload(result: E2EResult, *, command: str = "parallel-delivery patch-swarm e2e") -> dict[str, Any]: + return { + "ok": result.ok, + "command": command, + "state": "fixture_e2e_completed" if result.ok else "fixture_e2e_failed", + "dry_run": True, + "live_pro": False, + "fixture": True, + "run_id": result.run_id, + "run_dir": rel(result.run_dir), + "candidate_target": result.candidate_target, + "candidate_count": result.candidate_count, + "max_parallel_agents": result.max_parallel_agents, + "simulated_worker_batches": (result.candidate_count + result.max_parallel_agents - 1) // result.max_parallel_agents, + "accepted_patch_bundles": result.accepted_patch_bundles, + "rejected_patch_bundles": result.rejected_patch_bundles, + "overall": result.overall, + "validation_summary": rel(result.run_dir / "validation-summary.json"), + "validation_report": rel(result.run_dir / "validation-report.md"), + "artifacts": result.artifacts, + "warnings": result.warnings, + "errors": result.errors, + } + + +def request_from_args(args: argparse.Namespace, *, command: str = "parallel-delivery patch-swarm e2e") -> E2ERequest: + output_dir = Path(getattr(args, "output_dir", "") or "") if getattr(args, "output_dir", "") else None + run_id = getattr(args, "run_id", "") or (output_dir.name if output_dir else f"fixture-e2e-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}") + run_root = output_dir.parent if output_dir else Path(getattr(args, "run_root", "") or DEFAULT_RUN_ROOT) + return E2ERequest( + run_id=run_id, + run_root=run_root, + candidate_target=int(getattr(args, "candidate_target", 100) or 100), + max_parallel_agents=int(getattr(args, "max_parallel_agents", 5) or 5), + fixture=True, + dry_run=True, + fixed_timestamp=getattr(args, "fixed_timestamp", "") or None, + include_unsafe_fixture=bool(getattr(args, "include_unsafe_fixture", True)), + objective=getattr(args, "objective", "") or "", + command=command, + ) + + +def run_from_args(args: argparse.Namespace, *, command: str = "parallel-delivery patch-swarm e2e") -> tuple[dict[str, Any], int]: + try: + result = run_fixture_e2e(request_from_args(args, command=command)) + payload = result_payload(result, command=command) + return payload, 0 if payload.get("ok") else 1 + except ValidationE2EError as exc: + output_dir = Path(getattr(args, "output_dir", "") or "") if getattr(args, "output_dir", "") else None + run_id = getattr(args, "run_id", "") or (output_dir.name if output_dir else "fixture-e2e") + run_root = output_dir.parent if output_dir else Path(getattr(args, "run_root", "") or DEFAULT_RUN_ROOT) + payload = { + "ok": False, + "command": command, + "state": "fixture_e2e_failed", + "dry_run": True, + "live_pro": False, + "fixture": True, + "run_id": run_id, + "run_dir": rel(e2e_run_dir(run_root, run_id)), + "candidate_target": int(getattr(args, "candidate_target", 0) or 0), + "candidate_count": 0, + "max_parallel_agents": int(getattr(args, "max_parallel_agents", 0) or 0), + "simulated_worker_batches": 0, + "accepted_patch_bundles": 0, + "rejected_patch_bundles": 0, + "overall": "failed", + "validation_summary": "", + "validation_report": "", + "artifacts": [], + "warnings": [], + "errors": [str(exc)], + } + return payload, 1 + + +def command_print_policy(args: argparse.Namespace) -> int: + print(stable_json_dumps(print_policy()) if args.json else stable_json_dumps(print_policy()), end="") + return 0 + + +def command_validate_run(args: argparse.Namespace) -> int: + payload = validate_e2e_run(Path(args.run_dir)) + print(stable_json_dumps(payload) if args.json else stable_json_dumps(payload), end="") + return 0 if payload.get("ok") else 1 + + +def command_run_fixture(args: argparse.Namespace) -> int: + payload, code = run_from_args(args) + print(stable_json_dumps(payload) if args.json else stable_json_dumps(payload), end="") + return code + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run deterministic Patch Swarm validation fixture E2E.") + sub = parser.add_subparsers(dest="command", required=True) + policy = sub.add_parser("print-policy") + policy.add_argument("--json", action="store_true") + policy.set_defaults(func=command_print_policy) + + run = sub.add_parser("run-fixture") + run.add_argument("--run-id", default="") + run.add_argument("--run-root", default=str(DEFAULT_RUN_ROOT)) + run.add_argument("--output-dir", default="", help="Exact run directory to write. Overrides --run-root when provided.") + run.add_argument("--candidate-target", type=int, default=100) + run.add_argument("--max-parallel-agents", type=int, default=5) + run.add_argument("--fixture", action="store_true", default=True) + run.add_argument("--dry-run", action=argparse.BooleanOptionalAction, default=True) + run.add_argument("--fixed-timestamp", default="") + run.add_argument("--include-unsafe-fixture", action=argparse.BooleanOptionalAction, default=True) + run.add_argument("--objective", default="") + run.add_argument("--json", action="store_true") + run.set_defaults(func=command_run_fixture) + + validate = sub.add_parser("validate-run") + validate.add_argument("--run-dir", required=True) + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_validate_run) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parallel_delivery_worker_status.py b/scripts/parallel_delivery_worker_status.py new file mode 100644 index 0000000..96134e9 --- /dev/null +++ b/scripts/parallel_delivery_worker_status.py @@ -0,0 +1,1387 @@ +#!/usr/bin/env python3 +"""Patch Swarm worker-pool planning and status artifacts. + +This helper plans bounded dry-run worker dispatch and writes local status +artifacts for Console/operator review. It never launches external agents, +mutates tmux/process state, applies patches, or writes Taskstream/Redmine state. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import shutil +import sys +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +CURRENT_SCHEMA_VERSION = 1 +MAX_CANDIDATE_TASKS = 100 +DEFAULT_STALE_AFTER_SECONDS = 3600 +PRODUCER = "cento.parallel-delivery.worker-status" +DEFAULT_TIMESTAMP = "2026-01-01T00:00:00Z" + +TASK_STATES = { + "pending", + "queued", + "dispatch_planned", + "active", + "completed", + "blocked", + "stale", + "failed", + "skipped", + "human_handoff", +} + +LEDGER_EVENT_TYPES = { + "queue_created", + "task_queued", + "dispatch_planned", + "dispatch_skipped_dry_run", + "worker_active", + "worker_completed", + "worker_blocked", + "worker_stale", + "worker_failed", + "status_snapshot", +} + +RISK_TYPES = { + "stale_worker", + "dirty_target", + "guarded_path", + "blocked_dependency", + "manual_review_required", + "missing_worker_packet", + "missing_lease", + "process_not_found", + "platform_status_unavailable", +} + +REQUIRED_FIXTURE_FILES = [ + "request.md", + "split-plan.json", + "task-graph.json", + "path-leases.json", + "worker-pool-plan.json", + "dry-run-dispatch.json", + "worker-queue-ledger.jsonl", + "worker-status.json", + "worker-status-report.md", + "stale-workers.json", + "process-visibility.json", + "console-status.json", + "start-here.md", +] + + +class WorkerStatusError(Exception): + """Raised when worker status planning or validation fails.""" + + +@dataclass(frozen=True) +class WorkerStatusRequest: + run_id: str + run_dir: Path + candidate_target: int + max_parallel_agents: int + dry_run: bool = True + fixture: bool = False + fixed_timestamp: str | None = None + command: str = "patch-swarm dispatch --dry-run" + + +def utc_now() -> str: + """Return the current UTC timestamp with second precision.""" + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def stable_json_dumps(payload: Any) -> str: + """Return deterministic JSON with sorted keys, two-space indent, and trailing newline.""" + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + """Write deterministic JSON artifact.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(stable_json_dumps(payload), encoding="utf-8") + + +def write_jsonl(path: Path, events: list[dict[str, Any]]) -> None: + """Write deterministic JSONL ledger.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(stable_json_dumps(event).replace("\n", " ").rstrip() + "\n" for event in events), encoding="utf-8") + + +def read_json(path: Path) -> dict[str, Any]: + """Read JSON and fail clearly.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise WorkerStatusError(f"missing JSON artifact: {rel(path)}") from exc + except json.JSONDecodeError as exc: + raise WorkerStatusError(f"invalid JSON in {rel(path)}: {exc}") from exc + if not isinstance(payload, dict): + raise WorkerStatusError(f"expected JSON object in {rel(path)}") + return payload + + +def read_jsonl(path: Path) -> list[dict[str, Any]]: + """Read JSONL and report invalid line numbers.""" + events: list[dict[str, Any]] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError as exc: + raise WorkerStatusError(f"missing JSONL artifact: {rel(path)}") from exc + for line_number, line in enumerate(lines, start=1): + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + raise WorkerStatusError(f"invalid JSONL in {rel(path)} line {line_number}: {exc}") from exc + if not isinstance(event, dict): + raise WorkerStatusError(f"expected JSON object in {rel(path)} line {line_number}") + events.append(event) + return events + + +def rel(path: Path) -> str: + """Return a repo-relative path when possible.""" + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def resolve_path(path: Path) -> Path: + """Resolve a path relative to the repo root.""" + return path if path.is_absolute() else ROOT / path + + +def safe_run_id(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "-" for ch in str(value)).strip("-._") + if not cleaned: + raise WorkerStatusError("run_id is required") + return cleaned + + +def timestamp_for(request: WorkerStatusRequest) -> str: + return request.fixed_timestamp or utc_now() + + +def parse_timestamp(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + + +def seconds_before(timestamp: str, seconds: int) -> str: + parsed = parse_timestamp(timestamp) + if parsed is None: + return timestamp + return (parsed - timedelta(seconds=seconds)).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def digest(*parts: str) -> str: + return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:12] + + +def validate_candidate_target(value: int) -> int: + """Require 1 <= value <= 100.""" + if not isinstance(value, int): + raise WorkerStatusError("candidate_target must be an integer") + if not 1 <= value <= MAX_CANDIDATE_TASKS: + raise WorkerStatusError("candidate_target must be between 1 and 100") + return value + + +def validate_max_parallel_agents(value: int, candidate_target: int) -> int: + """Require 1 <= value <= candidate_target.""" + if not isinstance(value, int): + raise WorkerStatusError("max_parallel_agents must be an integer") + if not 1 <= value <= candidate_target: + raise WorkerStatusError("max_parallel_agents must be between 1 and candidate_target") + return value + + +def load_task_inputs(run_dir: Path) -> dict[str, Any]: + """Load split-plan, task-graph, path-leases, and worker packets if present.""" + root = resolve_path(run_dir) + inputs: dict[str, Any] = {} + for key, relative in { + "split_plan": "split-plan.json", + "task_graph": "task-graph.json", + "path_leases": "path-leases.json", + "codex_packet_index": "codex-packet-index.json", + "worker_packet_index": "worker-packets/codex-packet-index.json", + }.items(): + path = root / relative + if path.exists(): + inputs[key] = read_json(path) + return inputs + + +def create_worker_batches(task_ids: list[str], max_parallel_agents: int, dependency_gates: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + """Create deterministic bounded batches.""" + _ = dependency_gates or [] + batches: list[dict[str, Any]] = [] + for index in range(0, len(task_ids), max_parallel_agents): + order = index // max_parallel_agents + 1 + batches.append( + { + "batch_id": f"batch-{order:04d}", + "batch_order": order, + "max_parallel_agents": max_parallel_agents, + "task_ids": task_ids[index : index + max_parallel_agents], + "dry_run": True, + "would_dispatch": True, + "blocked": False, + "reason": "non-overlapping leases and no dependency gate", + } + ) + return batches + + +def _fixture_lane(index: int) -> str: + lanes = ["builder", "validator", "docs-evidence", "coordinator", "builder"] + return lanes[(index - 1) % len(lanes)] + + +def _worker_profile(lane: str) -> str: + return { + "builder": "python-builder", + "validator": "test-writer", + "docs-evidence": "docs-evidence-writer", + "coordinator": "factory-planner", + "integrator": "safe-integrator", + }.get(lane, "python-builder") + + +def _risk_tier(index: int) -> str: + if index in {7, 8}: + return "high" + if index % 10 == 0: + return "medium" + return "low" if index % 3 else "medium" + + +def fixture_tasks(run_id: str, candidate_target: int) -> list[dict[str, Any]]: + tasks: list[dict[str, Any]] = [] + for index in range(1, candidate_target + 1): + task_id = f"task-{index:04d}" + lane = _fixture_lane(index) + owned_path = f"workspace/runs/parallel-delivery/{run_id}/task-work/{task_id}" + tasks.append( + { + "task_id": task_id, + "title": f"Worker status fixture {task_id} {lane} lane", + "summary": f"Produce deterministic worker status evidence for {task_id}.", + "story": f"As a Cento operator, I need bounded worker-pool status evidence for {task_id}.", + "lane": lane, + "state": "leased", + "risk_tier": _risk_tier(index), + "worker_profile": _worker_profile(lane), + "owned_paths": [owned_path], + "read_only_paths": [ + "docs/patch-swarm.md", + "docs/parallel-delivery/patch-swarm-worker-status.md", + ], + "dependencies": [], + "human_handoff": False, + "validation_commands": [ + "python3 -m json.tool data/tools.json >/dev/null", + f"test -f workspace/runs/parallel-delivery/{run_id}/worker-status.json", + ], + "expected_artifacts": [f"{owned_path}/evidence.json"], + "acceptance_contract": [ + "Worker dispatch remains dry-run unless an explicit live backend exists.", + "Owned paths remain non-overlapping across candidate tasks.", + "Status evidence includes active, pending, completed, blocked, stale, and failed counts.", + ], + "rejection_triggers": [ + "Launches an external worker in fixture mode.", + "Mutates tmux, process, Taskstream, Redmine, or patch state.", + "Touches another task's owned path.", + ], + "integration_notes": ["Later live dispatch must consume this plan through explicit opt-in gates."], + "evidence_pointers": [], + } + ) + return tasks + + +def write_fixture_inputs(run_dir: Path, *, run_id: str, candidate_target: int, max_parallel_agents: int, timestamp: str) -> dict[str, Any]: + root = resolve_path(run_dir) + root.mkdir(parents=True, exist_ok=True) + tasks = fixture_tasks(run_id, candidate_target) + request_text = "\n".join( + [ + "# Patch Swarm Worker Status Fixture", + "", + "This deterministic fixture represents 100 candidate tasks with a bounded dry-run worker pool.", + "", + "- External agents are not launched.", + "- Process and tmux state are not mutated.", + "- `max_parallel_agents` controls planned dispatch batches.", + "", + ] + ) + (root / "request.md").write_text(request_text, encoding="utf-8") + + split_plan = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "split-plan", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "candidate_count": candidate_target, + "candidate_target": candidate_target, + "max_parallel_agents": max_parallel_agents, + "provenance": {"producer": PRODUCER, "command": "write-fixture", "source": "fixture", "notes": []}, + "tasks": tasks, + "warnings": [], + "evidence_pointers": [], + } + write_json(root / "split-plan.json", split_plan) + + task_graph = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "task-graph", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "nodes": [ + { + "task_id": task["task_id"], + "lane": task["lane"], + "risk_tier": task["risk_tier"], + "human_handoff": False, + "owned_paths": task["owned_paths"], + } + for task in tasks + ], + "edges": [], + "topological_order": [task["task_id"] for task in tasks], + "warnings": [], + "evidence_pointers": [], + } + write_json(root / "task-graph.json", task_graph) + + leases = [] + for index, task in enumerate(tasks, start=1): + leases.append( + { + "lease_id": f"lease-{task['task_id']}-{digest(run_id, task['task_id'])}", + "task_id": task["task_id"], + "state": "active", + "created_at": timestamp, + "lane": task["lane"], + "risk_tier": task["risk_tier"], + "owned_paths": task["owned_paths"], + "read_only_paths": task["read_only_paths"], + "guarded_paths": ["data/tools.json", "data/cento-cli.json"] if index in {7, 8} else [], + "protected_paths": [".env", ".env.*", ".env.mcp", ".git/**"], + "dirty_owned_paths": [], + "requires_manual_review": index == 7, + "minimal_hunk_required": True, + "dependency_gates": ["manual_review_required"] if index == 7 else [], + "dependencies": [], + "parallel_group": f"batch-{((index - 1) // max_parallel_agents) + 1:04d}", + "evidence_pointers": [], + } + ) + path_leases = { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "path-leases", + "run_id": run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": {"producer": PRODUCER, "command": "write-fixture", "source": "fixture", "notes": []}, + "leases": leases, + "conflicts": [], + "warnings": [], + "evidence_pointers": [], + } + write_json(root / "path-leases.json", path_leases) + return {"split_plan": split_plan, "task_graph": task_graph, "path_leases": path_leases} + + +def _tasks_from_inputs(inputs: dict[str, Any], request: WorkerStatusRequest) -> list[dict[str, Any]]: + split_plan = inputs.get("split_plan") if isinstance(inputs.get("split_plan"), dict) else {} + tasks = split_plan.get("tasks") if isinstance(split_plan, dict) else None + if isinstance(tasks, list) and tasks: + return [task for task in tasks if isinstance(task, dict)] + return fixture_tasks(request.run_id, request.candidate_target) + + +def _lease_map(inputs: dict[str, Any]) -> dict[str, dict[str, Any]]: + path_leases = inputs.get("path_leases") if isinstance(inputs.get("path_leases"), dict) else {} + leases = path_leases.get("leases") if isinstance(path_leases, dict) else [] + result: dict[str, dict[str, Any]] = {} + for lease in leases if isinstance(leases, list) else []: + if isinstance(lease, dict) and lease.get("task_id"): + result[str(lease["task_id"])] = lease + return result + + +def _batch_for_task(batches: list[dict[str, Any]]) -> dict[str, str]: + mapping: dict[str, str] = {} + for batch in batches: + for task_id in batch.get("task_ids", []): + mapping[str(task_id)] = str(batch.get("batch_id") or "") + return mapping + + +def _fixture_plan_state(task_id: str) -> tuple[str, bool, str | None, bool, list[dict[str, Any]]]: + if task_id == "task-0007": + return ( + "blocked", + False, + "manual review fixture dependency gate", + False, + [ + { + "risk_id": "risk-0007", + "task_id": task_id, + "type": "manual_review_required", + "severity": "warning", + "reason": "fixture blocked task requires operator review", + "next_action": "inspect blocked task evidence before live dispatch", + } + ], + ) + if task_id == "task-0008": + return ( + "stale", + False, + "last heartbeat older than stale_after_seconds", + True, + [ + { + "risk_id": "risk-0008", + "task_id": task_id, + "type": "stale_worker", + "severity": "warning", + "reason": "last heartbeat older than stale_after_seconds", + "next_action": "inspect handoff/evidence before requeue", + } + ], + ) + return ("pending", True, None, False, []) + + +def create_worker_pool_plan(request: WorkerStatusRequest, inputs: dict[str, Any]) -> dict[str, Any]: + """Create worker-pool-plan.json payload.""" + timestamp = timestamp_for(request) + tasks = _tasks_from_inputs(inputs, request) + task_ids = [str(task.get("task_id") or f"task-{index:04d}") for index, task in enumerate(tasks, start=1)] + batches = create_worker_batches(task_ids, request.max_parallel_agents) + batch_map = _batch_for_task(batches) + leases = _lease_map(inputs) + plan_tasks: list[dict[str, Any]] = [] + warnings: list[str] = [] + for task in tasks: + task_id = str(task.get("task_id") or "") + lease = leases.get(task_id, {}) + state, eligible, blocked_reason, stale, risks = _fixture_plan_state(task_id) + if not lease: + eligible = False + warnings.append(f"missing lease for {task_id}") + risks = risks + [ + { + "risk_id": f"risk-missing-lease-{task_id}", + "task_id": task_id, + "type": "missing_lease", + "severity": "error", + "reason": "task has no path lease", + "next_action": "create or repair path-leases.json before dispatch", + } + ] + if bool(task.get("human_handoff")): + eligible = False + state = "human_handoff" + blocked_reason = "human handoff tasks are not auto-dispatched" + plan_tasks.append( + { + "task_id": task_id, + "lane": str(task.get("lane") or lease.get("lane") or "builder"), + "state": state, + "risk_tier": str(task.get("risk_tier") or lease.get("risk_tier") or "medium"), + "worker_profile": str(task.get("worker_profile") or _worker_profile(str(task.get("lane") or "builder"))), + "lease_id": str(lease.get("lease_id") or f"lease-{task_id}-{digest(request.run_id, task_id)}"), + "owned_paths": list(lease.get("owned_paths") or task.get("owned_paths") or []), + "read_only_paths": list(lease.get("read_only_paths") or task.get("read_only_paths") or []), + "dependencies": list(task.get("dependencies") or lease.get("dependencies") or []), + "dependency_gates": list(lease.get("dependency_gates") or ([lease.get("dependency_gate")] if lease.get("dependency_gate") else [])), + "parallel_group": batch_map.get(task_id), + "dispatch_eligible": bool(eligible), + "blocked_reason": blocked_reason, + "stale": bool(stale), + "risk_indicators": risks, + } + ) + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "worker-pool-plan", + "run_id": request.run_id, + "created_at": timestamp, + "updated_at": timestamp, + "provenance": { + "producer": PRODUCER, + "command": request.command, + "source": "split-plan/task-graph/path-leases", + "notes": [], + }, + "candidate_count": len(plan_tasks), + "max_parallel_agents": request.max_parallel_agents, + "dry_run": bool(request.dry_run), + "launch_external_agents": False, + "dispatch_policy": { + "bounded_workers": True, + "no_blind_100_worker_launch": True, + "respect_dependency_gates": True, + "respect_path_leases": True, + "human_handoff_not_auto_dispatched": True, + "platform_safe": True, + }, + "batches": batches, + "tasks": plan_tasks, + "warnings": sorted(dict.fromkeys(warnings)), + "evidence_pointers": [ + "split-plan.json", + "task-graph.json", + "path-leases.json", + ], + } + + +def create_dry_run_dispatch(request: WorkerStatusRequest, worker_pool_plan: dict[str, Any]) -> dict[str, Any]: + """Create dry-run-dispatch.json without launching anything.""" + timestamp = timestamp_for(request) + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "dry-run-dispatch", + "run_id": request.run_id, + "created_at": timestamp, + "dry_run": True, + "live_dispatch": False, + "candidate_count": int(worker_pool_plan.get("candidate_count") or 0), + "max_parallel_agents": int(worker_pool_plan.get("max_parallel_agents") or request.max_parallel_agents), + "planned_batches": len(worker_pool_plan.get("batches") or []), + "planned_workers": int(worker_pool_plan.get("candidate_count") or 0), + "external_launches": [], + "commands_that_would_run": [], + "commands_not_run": [ + "cento agent-pool-kick", + "external Codex launch", + "tmux/session mutation", + ], + "reason": "fixture dry-run dispatch only" if request.fixture else "dry-run dispatch only", + "evidence_pointers": [ + "worker-pool-plan.json", + "worker-queue-ledger.jsonl", + ], + } + + +def _status_state(task_id: str) -> str: + if task_id in {f"task-{index:04d}" for index in range(1, 6)}: + return "active" + if task_id == "task-0006": + return "completed" + if task_id == "task-0007": + return "blocked" + if task_id == "task-0008": + return "stale" + return "pending" + + +def _status_tasks(request: WorkerStatusRequest, worker_pool_plan: dict[str, Any]) -> list[dict[str, Any]]: + timestamp = timestamp_for(request) + stale_heartbeat = seconds_before(timestamp, DEFAULT_STALE_AFTER_SECONDS + 3600) + tasks: list[dict[str, Any]] = [] + for plan_task in worker_pool_plan.get("tasks") or []: + task_id = str(plan_task.get("task_id") or "") + state = _status_state(task_id) + risk_indicators = list(plan_task.get("risk_indicators") or []) + blocked_reason = plan_task.get("blocked_reason") if state == "blocked" else None + worker_id = f"fixture-worker-{task_id}" if state in {"active", "completed", "stale"} else None + last_heartbeat_at = stale_heartbeat if state == "stale" else (timestamp if state in {"active", "completed"} else None) + updated_at = stale_heartbeat if state == "stale" else timestamp + started_at = timestamp if state in {"active", "completed", "stale"} else None + stale = state == "stale" + if state == "blocked" and not risk_indicators: + risk_indicators.append( + { + "risk_id": "risk-0007", + "task_id": task_id, + "type": "manual_review_required", + "severity": "warning", + "reason": "fixture blocked task requires operator review", + "next_action": "inspect blocked task evidence before live dispatch", + } + ) + if state == "stale" and not any(item.get("type") == "stale_worker" for item in risk_indicators): + risk_indicators.append( + { + "risk_id": "risk-0008", + "task_id": task_id, + "type": "stale_worker", + "severity": "warning", + "reason": "last heartbeat older than stale_after_seconds", + "next_action": "inspect handoff/evidence before requeue", + } + ) + tasks.append( + { + "task_id": task_id, + "lane": str(plan_task.get("lane") or "builder"), + "state": state, + "batch_id": plan_task.get("parallel_group"), + "worker_id": worker_id, + "process_id": None, + "process_status": "dry_run_not_launched", + "started_at": started_at, + "updated_at": updated_at, + "last_heartbeat_at": last_heartbeat_at, + "stale": stale, + "blocked_reason": blocked_reason, + "risk_indicators": risk_indicators, + "evidence_path": f"workers/{task_id}/evidence/", + } + ) + return tasks + + +def create_queue_ledger(request: WorkerStatusRequest, worker_pool_plan: dict[str, Any], status_tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Create worker-queue-ledger.jsonl events.""" + timestamp = timestamp_for(request) + task_status = {str(task.get("task_id")): task for task in status_tasks} + events: list[dict[str, Any]] = [] + + def add(event_type: str, task_id: str | None, batch_id: str | None, state: str, details: dict[str, Any] | None = None) -> None: + if event_type not in LEDGER_EVENT_TYPES: + raise WorkerStatusError(f"unsupported ledger event type: {event_type}") + events.append( + { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "worker-queue-event", + "event_id": f"event-{len(events) + 1:06d}", + "run_id": request.run_id, + "task_id": task_id, + "batch_id": batch_id, + "event_type": event_type, + "state": state, + "created_at": timestamp, + "actor": "patch-swarm-fixture" if request.fixture else "patch-swarm-worker-status", + "dry_run": True, + "details": details or {}, + } + ) + + add("queue_created", None, None, "pending", {"candidate_count": worker_pool_plan.get("candidate_count"), "max_parallel_agents": request.max_parallel_agents}) + for plan_task in worker_pool_plan.get("tasks") or []: + task_id = str(plan_task.get("task_id") or "") + batch_id = str(plan_task.get("parallel_group") or "") + state = str(task_status.get(task_id, {}).get("state") or "pending") + add("task_queued", task_id, batch_id, "pending", {"dispatch_eligible": bool(plan_task.get("dispatch_eligible"))}) + if bool(plan_task.get("dispatch_eligible")) and state in {"active", "pending", "completed"}: + add("dispatch_planned", task_id, batch_id, "dispatch_planned") + add("dispatch_skipped_dry_run", task_id, batch_id, "skipped", {"reason": "dry-run dispatch only"}) + if state == "active": + add("worker_active", task_id, batch_id, state, {"worker_id": task_status[task_id].get("worker_id")}) + elif state == "completed": + add("worker_completed", task_id, batch_id, state, {"worker_id": task_status[task_id].get("worker_id")}) + elif state == "blocked": + add("worker_blocked", task_id, batch_id, state, {"blocked_reason": task_status[task_id].get("blocked_reason")}) + elif state == "stale": + add("worker_stale", task_id, batch_id, state, {"last_heartbeat_at": task_status[task_id].get("last_heartbeat_at")}) + elif state == "failed": + add("worker_failed", task_id, batch_id, state) + counts = Counter(str(task.get("state") or "unknown") for task in status_tasks) + add("status_snapshot", None, None, "snapshot", {"counts": dict(sorted(counts.items()))}) + return events + + +def detect_stale_workers(tasks: list[dict[str, Any]], *, now: str, stale_after_seconds: int = DEFAULT_STALE_AFTER_SECONDS) -> list[dict[str, Any]]: + """Return stale worker indicators.""" + now_dt = parse_timestamp(now) + indicators: list[dict[str, Any]] = [] + for task in tasks: + task_id = str(task.get("task_id") or "") + heartbeat = parse_timestamp(task.get("last_heartbeat_at")) + stale = bool(task.get("stale")) + if now_dt and heartbeat and (now_dt - heartbeat).total_seconds() > stale_after_seconds: + stale = True + if str(task.get("state") or "") == "stale": + stale = True + if stale: + indicators.append( + { + "risk_id": f"risk-stale-{task_id}", + "task_id": task_id, + "type": "stale_worker", + "severity": "warning", + "reason": "last heartbeat older than stale_after_seconds", + "next_action": "inspect handoff/evidence before requeue", + "last_heartbeat_at": task.get("last_heartbeat_at"), + "stale_after_seconds": stale_after_seconds, + } + ) + return indicators + + +def create_worker_status(request: WorkerStatusRequest, worker_pool_plan: dict[str, Any], ledger_events: list[dict[str, Any]]) -> dict[str, Any]: + """Create worker-status.json payload.""" + timestamp = timestamp_for(request) + tasks = _status_tasks(request, worker_pool_plan) + counts = Counter(str(task.get("state") or "unknown") for task in tasks) + stale_workers = detect_stale_workers(tasks, now=timestamp) + risk_indicators = stale_workers[:] + for task in tasks: + if task.get("state") == "blocked": + risk_indicators.extend(task.get("risk_indicators") or []) + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "worker-status", + "run_id": request.run_id, + "created_at": timestamp, + "updated_at": timestamp, + "source_artifacts": { + "worker_pool_plan": "worker-pool-plan.json", + "queue_ledger": "worker-queue-ledger.jsonl", + "split_plan": "split-plan.json", + "task_graph": "task-graph.json", + "path_leases": "path-leases.json", + }, + "summary": { + "candidate_tasks": len(tasks), + "max_parallel_agents": request.max_parallel_agents, + "active": counts.get("active", 0), + "pending": counts.get("pending", 0), + "completed": counts.get("completed", 0), + "blocked": counts.get("blocked", 0), + "stale": counts.get("stale", 0), + "failed": counts.get("failed", 0), + "dry_run": True, + }, + "tasks": tasks, + "batches": worker_pool_plan.get("batches") or [], + "stale_workers": stale_workers, + "risk_indicators": risk_indicators, + "next_actions": [ + "Review blocked/stale task indicators before live dispatch.", + "Keep external worker launch disabled unless an explicit live backend is validated.", + ], + "warnings": [ + "dry-run status fixture only; no external agents were launched", + ], + "evidence_pointers": [ + "worker-pool-plan.json", + "dry-run-dispatch.json", + "worker-queue-ledger.jsonl", + f"ledger_events={len(ledger_events)}", + ], + } + + +def create_process_visibility(request: WorkerStatusRequest, worker_status: dict[str, Any] | None = None) -> dict[str, Any]: + """Create process-visibility.json with platform guards and read-only integration metadata.""" + timestamp = timestamp_for(request) + system = platform.system().lower() or "unknown" + if system not in {"linux", "darwin", "windows"}: + system = "unknown" + cento_available = shutil.which("cento") is not None + tasks = [] + for task in (worker_status or {}).get("tasks", []): + tasks.append( + { + "task_id": task.get("task_id"), + "worker_id": task.get("worker_id"), + "process_id": None, + "process_status": "dry_run_not_launched", + "read_only": True, + } + ) + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "process-visibility", + "run_id": request.run_id, + "created_at": timestamp, + "platform": { + "system": system, + "process_status_supported": False, + }, + "integrations": { + "agent_processes": { + "available": cento_available, + "status_command": "cento agent-processes", + "read_only": True, + "notes": ["Use `cento agent-processes --once` for operator status; fixture does not invoke it."], + }, + "agent_pool_kick": { + "available": cento_available, + "dry_run_supported": True, + "launch_not_performed": True, + "notes": ["`cento agent-pool-kick --dry-run` is compatible metadata only; not called by this fixture."], + }, + "cluster": { + "available": cento_available, + "status_command": "cento cluster status", + "read_only": True, + "notes": ["Cluster status is read-only; fixture does not probe or heal nodes."], + }, + "bridge": { + "available": cento_available, + "status_command": "cento bridge status", + "read_only": True, + "notes": ["Bridge status is read-only; fixture does not start, stop, or restart tunnels."], + }, + }, + "tasks": tasks, + "warnings": ["platform_status_unavailable: no portable process inspection was performed for fixture tasks"], + "evidence_pointers": ["worker-status.json"], + } + + +def create_console_status(worker_status: dict[str, Any], process_visibility: dict[str, Any]) -> dict[str, Any]: + """Create Console/UI-friendly status JSON.""" + summary = worker_status.get("summary") or {} + risk_indicators = worker_status.get("risk_indicators") or [] + risk = "warning" if summary.get("blocked") or summary.get("stale") or risk_indicators else "ok" + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "parallel-delivery-console-status", + "run_id": worker_status.get("run_id"), + "title": "Patch Swarm Worker Status Fixture", + "state": "dry_run_dispatch_planned", + "candidate_tasks": summary.get("candidate_tasks", 0), + "max_parallel_agents": summary.get("max_parallel_agents", 0), + "active": summary.get("active", 0), + "pending": summary.get("pending", 0), + "completed": summary.get("completed", 0), + "blocked": summary.get("blocked", 0), + "stale": summary.get("stale", 0), + "failed": summary.get("failed", 0), + "risk": risk, + "stale_indicators": worker_status.get("stale_workers") or [], + "risk_indicators": risk_indicators, + "process_visibility": { + "platform": process_visibility.get("platform", {}), + "agent_processes_read_only": ((process_visibility.get("integrations") or {}).get("agent_processes") or {}).get("read_only", True), + "cluster_read_only": ((process_visibility.get("integrations") or {}).get("cluster") or {}).get("read_only", True), + "bridge_read_only": ((process_visibility.get("integrations") or {}).get("bridge") or {}).get("read_only", True), + }, + "links": { + "worker_status": "worker-status.json", + "queue_ledger": "worker-queue-ledger.jsonl", + "worker_pool_plan": "worker-pool-plan.json", + "report": "worker-status-report.md", + }, + "next_operator_action": "Review blocked/stale task indicators before live dispatch.", + } + + +def create_stale_workers_payload(request: WorkerStatusRequest, worker_status: dict[str, Any]) -> dict[str, Any]: + timestamp = timestamp_for(request) + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "stale-workers", + "run_id": request.run_id, + "created_at": timestamp, + "stale_after_seconds": DEFAULT_STALE_AFTER_SECONDS, + "stale_workers": worker_status.get("stale_workers") or [], + "risk_indicators": [item for item in worker_status.get("risk_indicators") or [] if item.get("type") == "stale_worker"], + "warnings": [], + "evidence_pointers": ["worker-status.json"], + } + + +def write_worker_status_report(run_dir: Path, worker_status: dict[str, Any], console_status: dict[str, Any]) -> Path: + """Write worker-status-report.md.""" + root = resolve_path(run_dir) + summary = worker_status.get("summary") or {} + lines = [ + "# Patch Swarm Worker Status Report", + "", + "## Summary", + "", + f"- Run ID: `{worker_status.get('run_id')}`", + f"- Candidate tasks: `{summary.get('candidate_tasks')}`", + f"- Max parallel agents: `{summary.get('max_parallel_agents')}`", + f"- Active: `{summary.get('active')}`", + f"- Pending: `{summary.get('pending')}`", + f"- Completed: `{summary.get('completed')}`", + f"- Blocked: `{summary.get('blocked')}`", + f"- Stale: `{summary.get('stale')}`", + f"- Failed: `{summary.get('failed')}`", + f"- Dry run: `{summary.get('dry_run')}`", + "", + "## Bounded Dispatch", + "", + "The worker pool represents all candidate tasks but only plans bounded batches. No external agents were launched.", + "", + "## Stale And Blocked Tasks", + "", + ] + for item in worker_status.get("risk_indicators") or []: + lines.append(f"- `{item.get('task_id')}` `{item.get('type')}` {item.get('reason')}") + lines.extend( + [ + "", + "## Console Status", + "", + f"- State: `{console_status.get('state')}`", + f"- Risk: `{console_status.get('risk')}`", + f"- Next operator action: {console_status.get('next_operator_action')}", + "", + "## Artifacts", + "", + "- `worker-pool-plan.json`", + "- `dry-run-dispatch.json`", + "- `worker-queue-ledger.jsonl`", + "- `worker-status.json`", + "- `stale-workers.json`", + "- `process-visibility.json`", + "- `console-status.json`", + "", + ] + ) + path = root / "worker-status-report.md" + path.write_text("\n".join(lines), encoding="utf-8") + return path + + +def write_start_here(run_dir: Path) -> None: + root = resolve_path(run_dir) + lines = [ + "# Patch Swarm Worker Status Fixture", + "", + "Open `console-status.json` for the compact UI payload, then inspect `worker-status.json` and `worker-queue-ledger.jsonl` for task detail.", + "", + "No external agents were launched. `dry-run-dispatch.json` records launch commands that were intentionally not run.", + "", + ] + (root / "start-here.md").write_text("\n".join(lines), encoding="utf-8") + + +def status_envelope(request: WorkerStatusRequest, worker_status: dict[str, Any], *, command: str, errors: list[str] | None = None, warnings: list[str] | None = None) -> dict[str, Any]: + summary = worker_status.get("summary") or {} + run_dir = resolve_path(request.run_dir) + return { + "ok": not errors, + "command": command, + "state": "worker_status_ready" if not errors else "worker_status_failed", + "dry_run": True, + "live_dispatch": False, + "run_id": request.run_id, + "run_dir": rel(run_dir), + "candidate_tasks": summary.get("candidate_tasks", 0), + "max_parallel_agents": summary.get("max_parallel_agents", request.max_parallel_agents), + "active": summary.get("active", 0), + "pending": summary.get("pending", 0), + "completed": summary.get("completed", 0), + "blocked": summary.get("blocked", 0), + "stale": summary.get("stale", 0), + "failed": summary.get("failed", 0), + "worker_status": rel(run_dir / "worker-status.json"), + "console_status": rel(run_dir / "console-status.json"), + "queue_ledger": rel(run_dir / "worker-queue-ledger.jsonl"), + "worker_pool_plan": rel(run_dir / "worker-pool-plan.json"), + "warnings": warnings or [], + "errors": errors or [], + } + + +def build_worker_status_fixture( + run_dir: Path, + *, + run_id: str, + candidate_target: int, + max_parallel_agents: int, + timestamp: str, +) -> dict[str, Any]: + """Generate deterministic 100-task worker status fixture.""" + run_id = safe_run_id(run_id) + candidate_target = validate_candidate_target(candidate_target) + max_parallel_agents = validate_max_parallel_agents(max_parallel_agents, candidate_target) + request = WorkerStatusRequest( + run_id=run_id, + run_dir=run_dir, + candidate_target=candidate_target, + max_parallel_agents=max_parallel_agents, + dry_run=True, + fixture=True, + fixed_timestamp=timestamp, + command="patch-swarm dispatch --dry-run --fixture", + ) + root = resolve_path(run_dir) + inputs = write_fixture_inputs(root, run_id=run_id, candidate_target=candidate_target, max_parallel_agents=max_parallel_agents, timestamp=timestamp) + plan = create_worker_pool_plan(request, inputs) + status_tasks = _status_tasks(request, plan) + ledger = create_queue_ledger(request, plan, status_tasks) + worker_status = create_worker_status(request, plan, ledger) + process_visibility = create_process_visibility(request, worker_status) + console_status = create_console_status(worker_status, process_visibility) + stale_workers = create_stale_workers_payload(request, worker_status) + dispatch = create_dry_run_dispatch(request, plan) + + write_json(root / "worker-pool-plan.json", plan) + write_json(root / "dry-run-dispatch.json", dispatch) + write_jsonl(root / "worker-queue-ledger.jsonl", ledger) + write_json(root / "worker-status.json", worker_status) + write_json(root / "stale-workers.json", stale_workers) + write_json(root / "process-visibility.json", process_visibility) + write_json(root / "console-status.json", console_status) + write_worker_status_report(root, worker_status, console_status) + write_start_here(root) + return status_envelope(request, worker_status, command="parallel-delivery patch-swarm dispatch") + + +def plan_dispatch( + run_dir: Path, + *, + run_id: str | None = None, + candidate_target: int | None = None, + max_parallel_agents: int | None = None, + dry_run: bool = True, + live: bool = False, + timestamp: str | None = None, + fixture: bool = False, +) -> dict[str, Any]: + if live: + return { + "ok": False, + "command": "parallel-delivery patch-swarm dispatch", + "state": "live_dispatch_unsupported", + "dry_run": False, + "live_dispatch": True, + "run_id": run_id or resolve_path(run_dir).name, + "run_dir": rel(resolve_path(run_dir)), + "candidate_tasks": 0, + "max_parallel_agents": max_parallel_agents or 0, + "warnings": [], + "errors": ["live dispatch is not supported by worker-status; use an explicit existing live backend"], + } + root = resolve_path(run_dir) + if fixture: + return build_worker_status_fixture( + root, + run_id=run_id or root.name, + candidate_target=candidate_target or MAX_CANDIDATE_TASKS, + max_parallel_agents=max_parallel_agents or 5, + timestamp=timestamp or DEFAULT_TIMESTAMP, + ) + inputs = load_task_inputs(root) + inferred_target = candidate_target + if inferred_target is None: + split_plan = inputs.get("split_plan") if isinstance(inputs.get("split_plan"), dict) else {} + tasks = split_plan.get("tasks") if isinstance(split_plan, dict) else [] + inferred_target = len(tasks) if isinstance(tasks, list) and tasks else MAX_CANDIDATE_TASKS + inferred_target = validate_candidate_target(int(inferred_target)) + inferred_max = max_parallel_agents + if inferred_max is None and (root / "worker-pool-plan.json").exists(): + try: + inferred_max = int(read_json(root / "worker-pool-plan.json").get("max_parallel_agents") or 0) + except WorkerStatusError: + inferred_max = None + inferred_max = validate_max_parallel_agents(int(inferred_max or 5), inferred_target) + request = WorkerStatusRequest( + run_id=safe_run_id(run_id or root.name), + run_dir=root, + candidate_target=inferred_target, + max_parallel_agents=inferred_max, + dry_run=dry_run, + fixture=False, + fixed_timestamp=timestamp, + command="patch-swarm dispatch --dry-run", + ) + plan = create_worker_pool_plan(request, inputs) + status_tasks = _status_tasks(request, plan) + ledger = create_queue_ledger(request, plan, status_tasks) + worker_status = create_worker_status(request, plan, ledger) + process_visibility = create_process_visibility(request, worker_status) + console_status = create_console_status(worker_status, process_visibility) + write_json(root / "worker-pool-plan.json", plan) + write_json(root / "dry-run-dispatch.json", create_dry_run_dispatch(request, plan)) + write_jsonl(root / "worker-queue-ledger.jsonl", ledger) + write_json(root / "worker-status.json", worker_status) + write_json(root / "stale-workers.json", create_stale_workers_payload(request, worker_status)) + write_json(root / "process-visibility.json", process_visibility) + write_json(root / "console-status.json", console_status) + write_worker_status_report(root, worker_status, console_status) + write_start_here(root) + return status_envelope(request, worker_status, command="parallel-delivery patch-swarm dispatch") + + +def status_for_run(run_dir: Path) -> dict[str, Any]: + root = resolve_path(run_dir) + worker_status = read_json(root / "worker-status.json") + summary = worker_status.get("summary") or {} + request = WorkerStatusRequest( + run_id=str(worker_status.get("run_id") or root.name), + run_dir=root, + candidate_target=int(summary.get("candidate_tasks") or 1), + max_parallel_agents=int(summary.get("max_parallel_agents") or 1), + dry_run=True, + ) + return status_envelope(request, worker_status, command="parallel-delivery patch-swarm worker-status", warnings=list(worker_status.get("warnings") or [])) + + +def validate_worker_status_run(run_dir: Path) -> dict[str, Any]: + """Validate worker-pool/status artifacts and return JSON result.""" + root = resolve_path(run_dir) + errors: list[str] = [] + warnings: list[str] = [] + checked = [rel(root / item) for item in REQUIRED_FIXTURE_FILES if item != "worker-status-report.md" and item != "request.md" and item != "start-here.md"] + try: + plan = read_json(root / "worker-pool-plan.json") + dispatch = read_json(root / "dry-run-dispatch.json") + status = read_json(root / "worker-status.json") + console = read_json(root / "console-status.json") + process_visibility = read_json(root / "process-visibility.json") + stale_payload = read_json(root / "stale-workers.json") + ledger = read_jsonl(root / "worker-queue-ledger.jsonl") + except WorkerStatusError as exc: + return { + "ok": False, + "run_id": root.name, + "checked_artifacts": checked, + "summary": {}, + "errors": [str(exc)], + "warnings": warnings, + } + + tasks = plan.get("tasks") if isinstance(plan.get("tasks"), list) else [] + batches = plan.get("batches") if isinstance(plan.get("batches"), list) else [] + status_tasks = status.get("tasks") if isinstance(status.get("tasks"), list) else [] + max_agents = int(plan.get("max_parallel_agents") or 0) + candidate_count = int(plan.get("candidate_count") or 0) + if candidate_count != len(tasks): + errors.append("candidate_count does not equal worker-pool-plan task count") + seen: list[str] = [] + for batch in batches: + task_ids = [str(item) for item in batch.get("task_ids") or []] + if len(task_ids) > max_agents: + errors.append(f"batch {batch.get('batch_id')} exceeds max_parallel_agents") + seen.extend(task_ids) + if len(seen) != len(set(seen)): + errors.append("task appears in more than one batch") + plan_task_ids = {str(task.get("task_id")) for task in tasks} + if set(seen) != plan_task_ids: + errors.append("batches do not represent every planned task exactly once") + status_task_ids = {str(task.get("task_id")) for task in status_tasks} + if status_task_ids != plan_task_ids: + errors.append("worker-status tasks do not match worker-pool-plan tasks") + + summary_counts = Counter(str(task.get("state") or "unknown") for task in status_tasks) + summary = status.get("summary") if isinstance(status.get("summary"), dict) else {} + expected_summary = { + "candidate_tasks": len(status_tasks), + "max_parallel_agents": max_agents, + "active": summary_counts.get("active", 0), + "pending": summary_counts.get("pending", 0), + "completed": summary_counts.get("completed", 0), + "blocked": summary_counts.get("blocked", 0), + "stale": summary_counts.get("stale", 0), + "failed": summary_counts.get("failed", 0), + } + for key, value in expected_summary.items(): + if summary.get(key) != value: + errors.append(f"worker-status summary {key} is {summary.get(key)!r}, expected {value!r}") + if not any(task.get("state") == "stale" or task.get("stale") for task in status_tasks): + errors.append("stale fixture task was not detected") + if not any(task.get("state") == "blocked" for task in status_tasks): + errors.append("blocked fixture task was not detected") + if dispatch.get("dry_run") is not True or dispatch.get("live_dispatch") is not False: + errors.append("dry-run-dispatch does not clearly mark dry_run true and live_dispatch false") + if dispatch.get("external_launches"): + errors.append("dry-run-dispatch includes external launches") + if (process_visibility.get("integrations") or {}).get("agent_processes", {}).get("read_only") is not True: + errors.append("agent_processes integration is not read-only") + if (process_visibility.get("integrations") or {}).get("cluster", {}).get("read_only") is not True: + errors.append("cluster integration is not read-only") + if (process_visibility.get("integrations") or {}).get("bridge", {}).get("read_only") is not True: + errors.append("bridge integration is not read-only") + if (process_visibility.get("integrations") or {}).get("agent_pool_kick", {}).get("launch_not_performed") is not True: + errors.append("agent_pool_kick launch_not_performed is not true") + if not process_visibility.get("platform") or "process_status_supported" not in process_visibility.get("platform", {}): + errors.append("process visibility platform guard missing") + event_types = {str(event.get("event_type")) for event in ledger} + required_events = {"queue_created", "task_queued", "dispatch_planned", "dispatch_skipped_dry_run", "status_snapshot"} + missing_events = required_events - event_types + if missing_events: + errors.append(f"queue ledger missing events: {sorted(missing_events)}") + if sum(1 for event in ledger if event.get("event_type") == "task_queued") != candidate_count: + errors.append("queue ledger does not include task_queued for every task") + if console.get("artifact_type") != "parallel-delivery-console-status": + errors.append("console-status.json is not a Console status artifact") + if not stale_payload.get("stale_workers"): + errors.append("stale-workers.json does not include stale workers") + return { + "ok": not errors, + "run_id": str(status.get("run_id") or root.name), + "checked_artifacts": checked, + "summary": { + "candidate_tasks": expected_summary["candidate_tasks"], + "max_parallel_agents": expected_summary["max_parallel_agents"], + "batches": len(batches), + "active": expected_summary["active"], + "pending": expected_summary["pending"], + "completed": expected_summary["completed"], + "blocked": expected_summary["blocked"], + "stale": expected_summary["stale"], + "failed": expected_summary["failed"], + }, + "errors": errors, + "warnings": warnings, + } + + +def print_policy() -> dict[str, Any]: + """Return local worker status policy.""" + return { + "schema_version": CURRENT_SCHEMA_VERSION, + "artifact_type": "worker-status-policy", + "producer": PRODUCER, + "local_only": True, + "dry_run_dispatch_default": True, + "no_external_launch_by_default": True, + "no_process_mutation": True, + "no_tmux_mutation": True, + "max_candidate_tasks": MAX_CANDIDATE_TASKS, + "default_stale_after_seconds": DEFAULT_STALE_AFTER_SECONDS, + "task_states": sorted(TASK_STATES), + "ledger_event_types": sorted(LEDGER_EVENT_TYPES), + "risk_types": sorted(RISK_TYPES), + "live_dispatch_supported": False, + } + + +def add_dispatch_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-dir", required=True) + parser.add_argument("--run-id", default="") + parser.add_argument("--candidate-target", type=int, default=MAX_CANDIDATE_TASKS) + parser.add_argument("--max-parallel-agents", type=int, default=5) + parser.add_argument("--dry-run", action="store_true", default=True) + parser.add_argument("--live", action="store_true") + parser.add_argument("--fixture", action="store_true") + parser.add_argument("--fixed-timestamp", default="") + parser.add_argument("--json", action="store_true") + + +def add_status_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-dir", required=True) + parser.add_argument("--json", action="store_true") + + +def command_print_policy(args: argparse.Namespace) -> int: + print(stable_json_dumps(print_policy()), end="") + return 0 + + +def command_write_fixture(args: argparse.Namespace) -> int: + try: + payload = build_worker_status_fixture( + Path(args.run_dir), + run_id=args.run_id, + candidate_target=int(args.candidate_target), + max_parallel_agents=int(args.max_parallel_agents), + timestamp=args.fixed_timestamp or DEFAULT_TIMESTAMP, + ) + return_code = 0 if payload.get("ok") else 1 + except WorkerStatusError as exc: + payload = {"ok": False, "run_id": args.run_id, "run_dir": args.run_dir, "errors": [str(exc)], "warnings": []} + return_code = 1 + print(stable_json_dumps(payload), end="") + return return_code + + +def command_plan_dispatch(args: argparse.Namespace) -> int: + try: + payload = plan_dispatch( + Path(args.run_dir), + run_id=args.run_id or None, + candidate_target=int(args.candidate_target) if getattr(args, "candidate_target", None) else None, + max_parallel_agents=int(args.max_parallel_agents) if getattr(args, "max_parallel_agents", None) else None, + dry_run=bool(getattr(args, "dry_run", True)), + live=bool(getattr(args, "live", False)), + timestamp=args.fixed_timestamp or None, + fixture=bool(getattr(args, "fixture", False)), + ) + return_code = 0 if payload.get("ok") else 1 + except WorkerStatusError as exc: + payload = {"ok": False, "run_id": args.run_id or Path(args.run_dir).name, "run_dir": args.run_dir, "errors": [str(exc)], "warnings": []} + return_code = 1 + print(stable_json_dumps(payload), end="") + return return_code + + +def command_status(args: argparse.Namespace) -> int: + try: + payload = status_for_run(Path(args.run_dir)) + return_code = 0 if payload.get("ok") else 1 + except WorkerStatusError as exc: + payload = {"ok": False, "run_id": Path(args.run_dir).name, "run_dir": args.run_dir, "errors": [str(exc)], "warnings": []} + return_code = 1 + print(stable_json_dumps(payload), end="") + return return_code + + +def command_validate_status(args: argparse.Namespace) -> int: + payload = validate_worker_status_run(Path(args.run_dir)) + print(stable_json_dumps(payload), end="") + return 0 if payload.get("ok") else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Plan and render Patch Swarm worker status artifacts.") + sub = parser.add_subparsers(dest="command", required=True) + + policy = sub.add_parser("print-policy", help="Print local worker-status policy.") + policy.add_argument("--json", action="store_true") + policy.set_defaults(func=command_print_policy) + + fixture = sub.add_parser("write-fixture", help="Write deterministic worker status fixture artifacts.") + fixture.add_argument("--run-dir", required=True) + fixture.add_argument("--run-id", default="worker-status-fixture") + fixture.add_argument("--candidate-target", type=int, default=MAX_CANDIDATE_TASKS) + fixture.add_argument("--max-parallel-agents", type=int, default=5) + fixture.add_argument("--fixed-timestamp", default=DEFAULT_TIMESTAMP) + fixture.add_argument("--json", action="store_true") + fixture.set_defaults(func=command_write_fixture) + + dispatch = sub.add_parser("plan-dispatch", help="Plan bounded dry-run dispatch from run artifacts.") + add_dispatch_args(dispatch) + dispatch.set_defaults(func=command_plan_dispatch) + + status = sub.add_parser("status", help="Print worker-status summary.") + add_status_args(status) + status.set_defaults(func=command_status) + + validate = sub.add_parser("validate-status", help="Validate worker status artifacts.") + validate.add_argument("--run-dir", required=True) + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_validate_status) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/patch_swarm_pro_calls.py b/scripts/patch_swarm_pro_calls.py new file mode 100644 index 0000000..4e2bf3f --- /dev/null +++ b/scripts/patch_swarm_pro_calls.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_REGISTRY = ROOT / "data" / "patch-swarm-pro-calls.json" +DEFAULT_EVIDENCE_ROOT = ROOT / "workspace" / "runs" / "parallel-delivery" / "pro-call-registry" + +SCHEMA_VERSION = "cento.patch_swarm.pro_call_registry.v1" +STATUSES = ("PENDING", "IN_PROGRESS", "CODEX_DONE", "CLOSED", "BLOCKED") +ALLOWED_TRANSITIONS = { + "PENDING": {"IN_PROGRESS", "BLOCKED"}, + "IN_PROGRESS": {"CODEX_DONE", "BLOCKED"}, + "CODEX_DONE": {"CLOSED", "BLOCKED"}, + "CLOSED": set(), + "BLOCKED": {"PENDING", "IN_PROGRESS", "CLOSED"}, +} + +SECRET_PATTERNS = [ + re.compile(r"sk-[A-Za-z0-9]{20,}"), + re.compile(r"(?i)\b(?:OPENAI_API_KEY|CENTO_OPENAI|ANTHROPIC_API_KEY)\s*=\s*['\"]?[A-Za-z0-9_\-]{12,}"), + re.compile(r"(?i)\b(?:api[_-]?key|token|secret)\s*[:=]\s*['\"]?(?:sk-)?[A-Za-z0-9_\-]{20,}"), +] + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def run_id() -> str: + return datetime.now(timezone.utc).strftime("pro-call-registry-%Y%m%dT%H%M%SZ") + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError(f"{path} must contain a JSON object") + return payload + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=False) + handle.write("\n") + tmp.replace(path) + + +def call_by_id(registry: dict[str, Any], call_id: int) -> dict[str, Any]: + for call in registry.get("calls", []): + if isinstance(call, dict) and call.get("call_id") == call_id: + return call + raise ValueError(f"call_id {call_id} not found") + + +def validate_registry(registry: dict[str, Any]) -> list[str]: + errors: list[str] = [] + if registry.get("schema_version") != SCHEMA_VERSION: + errors.append(f"schema_version must be {SCHEMA_VERSION!r}") + calls = registry.get("calls") + if not isinstance(calls, list): + errors.append("calls must be a list") + return errors + expected_ids = list(range(0, 101)) + if len(calls) != len(expected_ids): + errors.append(f"calls must contain exactly {len(expected_ids)} entries, found {len(calls)}") + + seen: set[int] = set() + for index, call in enumerate(calls): + if not isinstance(call, dict): + errors.append(f"calls[{index}] must be an object") + continue + call_id = call.get("call_id") + expected_call_id = expected_ids[index] if index < len(expected_ids) else index + if call_id != expected_call_id: + errors.append(f"calls[{index}] call_id must be {expected_call_id}, found {call_id!r}") + if isinstance(call_id, int): + if call_id in seen: + errors.append(f"duplicate call_id {call_id}") + seen.add(call_id) + call_label = call.get("call_label") + if call_label != f"CALL {call_id:02d}": + errors.append(f"call {call_id} call_label must be CALL {call_id:02d}, found {call_label!r}") + part = call.get("part") + if call_id is not None: + expected_part = 1 if call_id <= 30 else 2 if call_id <= 60 else 3 + if part != expected_part: + errors.append(f"call {call_id} part must be {expected_part}, found {part!r}") + status = call.get("status") + if status not in STATUSES: + errors.append(f"call {call_id} has invalid status {status!r}") + if not isinstance(call.get("title"), str) or not call.get("title"): + errors.append(f"call {call_id} title must be non-empty") + prompt = call.get("prompt") + placeholder = call.get("placeholder") + if not isinstance(prompt, str): + errors.append(f"call {call_id} prompt must be a string") + if not isinstance(call.get("Pro_output"), str): + errors.append(f"call {call_id} Pro_output must be a string") + if placeholder not in (True, False): + errors.append(f"call {call_id} placeholder must be boolean") + if placeholder is True and prompt != "": + errors.append(f"call {call_id} placeholder prompt must be empty") + if placeholder is False and not prompt: + errors.append(f"call {call_id} populated call prompt must be non-empty") + for list_field in ("depends_on", "codex_evidence", "events"): + if not isinstance(call.get(list_field), list): + errors.append(f"call {call_id} {list_field} must be a list") + return errors + + +def validate_output_text(text: str) -> list[str]: + findings: list[str] = [] + for pattern in SECRET_PATTERNS: + if pattern.search(text): + findings.append(f"secret-like content matched {pattern.pattern}") + return findings + + +def status_counts(registry: dict[str, Any]) -> dict[str, int]: + counts = {status: 0 for status in STATUSES} + for call in registry.get("calls", []): + status = call.get("status") + if status in counts: + counts[status] += 1 + return counts + + +def next_call(registry: dict[str, Any]) -> dict[str, Any] | None: + for status in ("IN_PROGRESS", "PENDING", "BLOCKED"): + for call in registry.get("calls", []): + if call.get("status") == status: + return call + return None + + +def append_event(call: dict[str, Any], event: dict[str, Any]) -> None: + events = call.setdefault("events", []) + if not isinstance(events, list): + raise ValueError(f"call {call.get('call_id')} events must be a list") + events.append({"timestamp": utc_now(), **event}) + + +def command_validate(args: argparse.Namespace) -> int: + registry = load_json(Path(args.registry)) + errors = validate_registry(registry) + payload = { + "schema_version": "cento.patch_swarm.pro_call_registry.validation.v1", + "registry": str(Path(args.registry)), + "status": "fail" if errors else "pass", + "counts": status_counts(registry), + "errors": errors, + } + if args.json: + print(json.dumps(payload, indent=2)) + else: + print(f"{payload['status']}: {payload['counts']}") + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 if errors else 0 + + +def command_stats(args: argparse.Namespace) -> int: + registry = load_json(Path(args.registry)) + payload = { + "schema_version": "cento.patch_swarm.pro_call_registry.stats.v1", + "registry": str(Path(args.registry)), + "calls": len(registry.get("calls", [])), + "counts": status_counts(registry), + "next": summarize_call(next_call(registry)), + } + print(json.dumps(payload, indent=2)) + return 0 + + +def summarize_call(call: dict[str, Any] | None) -> dict[str, Any] | None: + if call is None: + return None + return { + "call_id": call.get("call_id"), + "call_label": call.get("call_label"), + "part": call.get("part"), + "title": call.get("title"), + "status": call.get("status"), + "placeholder": call.get("placeholder"), + } + + +def command_next(args: argparse.Namespace) -> int: + registry = load_json(Path(args.registry)) + call = next_call(registry) + if args.json: + print(json.dumps({"next": summarize_call(call)}, indent=2)) + elif call is None: + print("No actionable calls remain.") + else: + print(f"Call {call['call_id']}: {call['title']} [{call['status']}]") + return 0 + + +def command_ingest(args: argparse.Namespace) -> int: + registry_path = Path(args.registry) + registry = load_json(registry_path) + errors = validate_registry(registry) + if errors: + raise SystemExit("registry validation failed before ingest:\n" + "\n".join(errors)) + text = Path(args.file).read_text(encoding="utf-8") + findings = validate_output_text(text) + call = call_by_id(registry, args.call_id) + if findings and not args.allow_secret_like: + append_event( + call, + { + "actor": "pro-loop", + "event": "pro_output_rejected", + "previous_status": call.get("status"), + "next_status": "BLOCKED", + "findings": findings, + }, + ) + call["status"] = "BLOCKED" + registry["updated_at"] = utc_now() + write_json(registry_path, registry) + print(json.dumps({"status": "blocked", "findings": findings}, indent=2)) + return 2 + + previous_status = call.get("status") + call["Pro_output"] = text + call["pro_output_received_at"] = utc_now() + call["status"] = "IN_PROGRESS" + evidence_dir = Path(args.evidence_dir) if args.evidence_dir else DEFAULT_EVIDENCE_ROOT / run_id() + evidence_dir.mkdir(parents=True, exist_ok=True) + output_copy = evidence_dir / f"call-{args.call_id:03d}-pro-output.md" + output_copy.write_text(text, encoding="utf-8") + append_event( + call, + { + "actor": "pro-loop", + "event": "pro_output_ingested", + "previous_status": previous_status, + "next_status": "IN_PROGRESS", + "evidence": str(output_copy.relative_to(ROOT)) if output_copy.is_relative_to(ROOT) else str(output_copy), + }, + ) + registry["updated_at"] = utc_now() + write_json(registry_path, registry) + payload = { + "status": "ingested", + "call_id": args.call_id, + "previous_status": previous_status, + "next_status": "IN_PROGRESS", + "evidence": str(output_copy), + } + print(json.dumps(payload, indent=2)) + return 0 + + +def parse_prompt_title(text: str, call_id: int) -> str: + pattern = re.compile(rf"^\s*#?\s*CALL\s+{call_id:02d}\s*:\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE) + match = pattern.search(text) + if match: + return match.group(1).strip() + return f"CALL {call_id:02d} prompt" + + +def command_ingest_prompt(args: argparse.Namespace) -> int: + registry_path = Path(args.registry) + registry = load_json(registry_path) + errors = validate_registry(registry) + if errors: + raise SystemExit("registry validation failed before prompt ingest:\n" + "\n".join(errors)) + text = Path(args.file).read_text(encoding="utf-8") + if not text.strip(): + raise SystemExit("prompt file is empty") + findings = validate_output_text(text) + if findings and not args.allow_secret_like: + print(json.dumps({"status": "blocked", "findings": findings}, indent=2)) + return 2 + + call = call_by_id(registry, args.call_id) + previous_placeholder = bool(call.get("placeholder")) + call["prompt"] = text + call["placeholder"] = False + call["title"] = parse_prompt_title(text, args.call_id) + call["summary"] = f"Operator-supplied prompt for CALL {args.call_id:02d}." + append_event( + call, + { + "actor": "pro-loop", + "event": "prompt_ingested", + "previous_placeholder": previous_placeholder, + "next_placeholder": False, + }, + ) + registry["updated_at"] = utc_now() + evidence_dir = Path(args.evidence_dir) if args.evidence_dir else DEFAULT_EVIDENCE_ROOT / run_id() + evidence_dir.mkdir(parents=True, exist_ok=True) + prompt_copy = evidence_dir / f"call-{args.call_id:03d}-prompt.md" + prompt_copy.write_text(text, encoding="utf-8") + call.setdefault("codex_evidence", []).append(str(prompt_copy.relative_to(ROOT)) if prompt_copy.is_relative_to(ROOT) else str(prompt_copy)) + write_json(registry_path, registry) + print( + json.dumps( + { + "status": "ingested", + "call_id": args.call_id, + "call_label": call.get("call_label"), + "title": call.get("title"), + "prompt_chars": len(text), + "evidence": str(prompt_copy), + }, + indent=2, + ) + ) + return 0 + + +def command_set_status(args: argparse.Namespace) -> int: + registry_path = Path(args.registry) + registry = load_json(registry_path) + errors = validate_registry(registry) + if errors: + raise SystemExit("registry validation failed before status update:\n" + "\n".join(errors)) + call = call_by_id(registry, args.call_id) + previous_status = call.get("status") + next_status = args.status + if next_status not in ALLOWED_TRANSITIONS.get(previous_status, set()) and not args.force: + raise SystemExit(f"invalid transition {previous_status} -> {next_status}; use --force only for repair") + call["status"] = next_status + if args.note: + call["notes"] = (str(call.get("notes") or "") + "\n" + args.note).strip() + append_event( + call, + { + "actor": "pro-loop", + "event": "status_updated", + "previous_status": previous_status, + "next_status": next_status, + "note": args.note or "", + "forced": bool(args.force), + }, + ) + registry["updated_at"] = utc_now() + write_json(registry_path, registry) + print(json.dumps({"status": "updated", "call_id": args.call_id, "previous_status": previous_status, "next_status": next_status}, indent=2)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Patch Swarm Pro call registry helper") + parser.add_argument("--registry", default=str(DEFAULT_REGISTRY), help="Path to patch-swarm Pro call registry JSON") + sub = parser.add_subparsers(dest="command", required=True) + + validate = sub.add_parser("validate", help="Validate registry shape and lifecycle fields") + validate.add_argument("--json", action="store_true", help="Print JSON validation payload") + validate.set_defaults(func=command_validate) + + stats = sub.add_parser("stats", help="Print registry status counts") + stats.set_defaults(func=command_stats) + + next_parser = sub.add_parser("next", help="Print next actionable call") + next_parser.add_argument("--json", action="store_true", help="Print JSON output") + next_parser.set_defaults(func=command_next) + + ingest = sub.add_parser("ingest-pro-output", help="Save Pro model output and mark a call IN_PROGRESS") + ingest.add_argument("--call-id", type=int, required=True) + ingest.add_argument("--file", required=True, help="Markdown/text file containing the Pro output") + ingest.add_argument("--evidence-dir", default="", help="Optional evidence directory for a copy of the output") + ingest.add_argument("--allow-secret-like", action="store_true", help="Repair-only override for secret-like text detection") + ingest.set_defaults(func=command_ingest) + + ingest_prompt = sub.add_parser("ingest-prompt", help="Save an operator-supplied call prompt without touching Pro_output") + ingest_prompt.add_argument("--call-id", type=int, required=True) + ingest_prompt.add_argument("--file", required=True, help="Markdown/text file containing the call prompt") + ingest_prompt.add_argument("--evidence-dir", default="", help="Optional evidence directory for a copy of the prompt") + ingest_prompt.add_argument("--allow-secret-like", action="store_true", help="Repair-only override for secret-like text detection") + ingest_prompt.set_defaults(func=command_ingest_prompt) + + set_status = sub.add_parser("set-status", help="Update a call lifecycle status") + set_status.add_argument("--call-id", type=int, required=True) + set_status.add_argument("--status", choices=STATUSES, required=True) + set_status.add_argument("--note", default="") + set_status.add_argument("--force", action="store_true", help="Allow repair transitions outside the normal lifecycle") + set_status.set_defaults(func=command_set_status) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/patch_swarm_product_e2e.py b/scripts/patch_swarm_product_e2e.py new file mode 100644 index 0000000..6262594 --- /dev/null +++ b/scripts/patch_swarm_product_e2e.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import threading +from datetime import datetime, timezone +from http.server import ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +from PIL import Image, ImageStat +from playwright.sync_api import sync_playwright + + +ROOT = Path(__file__).resolve().parents[1] +RUNS_ROOT = ROOT / "workspace" / "runs" / "patch-swarm-product-e2e" +VIEWPORTS = [(390, 900), (1365, 1000), (2048, 1000)] + +sys.path.insert(0, str(ROOT / "scripts")) +import agent_work_app as app # noqa: E402 +import parallel_delivery as pd # noqa: E402 + + +class ProductE2EError(RuntimeError): + pass + + +def now_id() -> str: + return datetime.now(timezone.utc).strftime("patch-swarm-product-e2e-%Y%m%dT%H%M%SZ") + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8") + + +def run_git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(["git", *args], cwd=repo, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + + +def init_fixture_repo(path: Path, *, dirty: str = "") -> Path: + path.mkdir(parents=True, exist_ok=True) + (path / "README.md").write_text(f"# {path.name}\n", encoding="utf-8") + subprocess.run(["git", "init"], cwd=path, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + run_git(path, "add", "README.md") + subprocess.run( + ["git", "-c", "user.email=e2e@example.com", "-c", "user.name=Patch Swarm E2E", "commit", "-m", "init"], + cwd=path, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if dirty == "unprotected": + (path / "notes.txt").write_text("local notes stay untouched\n", encoding="utf-8") + elif dirty == "protected": + (path / ".env").write_text("TOKEN=fixture\n", encoding="utf-8") + return path + + +def request_json(method: str, url: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=120) as response: + body = response.read().decode("utf-8") + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + try: + error_payload = json.loads(body) + except json.JSONDecodeError: + error_payload = {"error": body} + raise ProductE2EError(f"{method} {url} failed: {exc.code} {error_payload}") from exc + return json.loads(body) if body.strip() else {} + + +def start_server(db_path: Path) -> tuple[ThreadingHTTPServer, str]: + with app.connect(db_path) as conn: + app.init_db(conn) + server = ThreadingHTTPServer(("127.0.0.1", 0), app.make_handler(db_path)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, f"http://127.0.0.1:{server.server_port}" + + +def png_nonblank(path: Path) -> dict[str, Any]: + with Image.open(path) as image: + rgb = image.convert("RGB") + width, height = image.size + stat = ImageStat.Stat(rgb) + extrema = rgb.getextrema() + return { + "path": str(path.relative_to(ROOT)), + "width": width, + "height": height, + "stddev_max": max(stat.stddev), + "extrema": extrema, + "nonblank": max(stat.stddev) > 1.0, + } + + +def capture_screenshots(base_url: str, run_id: str, out_dir: Path) -> list[dict[str, Any]]: + screenshots: list[dict[str, Any]] = [] + routes = [ + ("index", "/patch-swarm"), + ("detail", f"/patch-swarm/runs/{run_id}"), + ] + screenshot_dir = out_dir / "screenshots" + screenshot_dir.mkdir(parents=True, exist_ok=True) + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + try: + for route_name, route in routes: + for width, height in VIEWPORTS: + page = browser.new_page(viewport={"width": width, "height": height}) + console_errors: list[str] = [] + page.on("console", lambda message: console_errors.append(message.text) if message.type == "error" else None) + page.goto(f"{base_url}{route}", wait_until="domcontentloaded", timeout=30_000) + page.wait_for_selector("#patchSwarmView:not(.hidden)", timeout=20_000) + if route_name == "detail": + page.wait_for_selector("#patchSwarmEvidence:not(.hidden)", timeout=20_000) + page.wait_for_timeout(700) + ui_checks = { + "fixture_mode_default": page.eval_on_selector("#patchSwarmMode", "el => el.value === 'fixture'"), + "start_disabled_empty_task": page.eval_on_selector("#patchSwarmStartButton", "el => el.disabled === true"), + "default_repo_is_clean": page.eval_on_selector("#patchSwarmRepoSelect", "el => /aa-clean-app/.test(el.value)"), + "no_horizontal_overflow": page.evaluate("document.documentElement.scrollWidth <= window.innerWidth + 1"), + "empty_state_correct": page.eval_on_selector( + "#patchSwarmDetailEmpty", + "(el, routeName) => routeName === 'detail' ? getComputedStyle(el).display === 'none' : getComputedStyle(el).display !== 'none'", + route_name, + ), + "stats_visibility_correct": page.eval_on_selector( + "#patchSwarmStats", + "(el, routeName) => routeName === 'detail' ? getComputedStyle(el).display !== 'none' : getComputedStyle(el).display === 'none'", + route_name, + ), + } + screenshot_path = screenshot_dir / f"{route_name}-{width}x{height}.png" + page.screenshot(path=str(screenshot_path), full_page=True) + blank_check = png_nonblank(screenshot_path) + screenshots.append( + { + "route": route, + "viewport": {"width": width, "height": height}, + "screenshot": str(screenshot_path.relative_to(ROOT)), + "nonblank": blank_check["nonblank"], + "blank_check": blank_check, + "ui_checks": ui_checks, + "console_errors": console_errors, + } + ) + page.close() + finally: + browser.close() + return screenshots + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run the Patch Swarm product release-candidate e2e fixture.") + parser.add_argument("--run-id", default=now_id()) + args = parser.parse_args() + + run_id = str(args.run_id) + summary_dir = RUNS_ROOT / run_id + fixture_root = summary_dir / "repos" + summary_dir.mkdir(parents=True, exist_ok=True) + + clean_repo = init_fixture_repo(fixture_root / "aa-clean-app") + dirty_repo = init_fixture_repo(fixture_root / "mm-dirty-app", dirty="unprotected") + protected_repo = init_fixture_repo(fixture_root / "zz-protected-app", dirty="protected") + + os.environ["CENTO_PATCH_SWARM_REPO_ROOTS"] = str(fixture_root) + pd.PATCH_SWARM_RUNS_ROOT = summary_dir / "parallel-delivery" / "patch-swarm" + pd.PIPELINE_ROOT = summary_dir / "dev-pipeline-studio" / "latest" + pd.FACTORY_RUNS_ROOT = summary_dir / "factory" + app.PATCH_SWARM_PRODUCT_WORKTREE_ROOT = summary_dir / "product-worktrees" + + server, base_url = start_server(summary_dir / "agent-work-app.sqlite3") + checks: list[dict[str, Any]] = [] + + def check(name: str, passed: bool, detail: str = "") -> None: + checks.append({"name": name, "status": "passed" if passed else "failed", "detail": detail}) + + try: + repos_payload = request_json("GET", f"{base_url}/api/patch-swarm/repos") + repos = {item["path"]: item for item in repos_payload.get("repos", []) if isinstance(item, dict)} + clean_state = repos.get(str(clean_repo.resolve()), {}) + dirty_state = repos.get(str(dirty_repo.resolve()), {}) + protected_state = repos.get(str(protected_repo.resolve()), {}) + check("repo.clean.startable", clean_state.get("can_start") is True and clean_state.get("safety_label") == "clean_startable") + check("repo.dirty.unprotected_startable", dirty_state.get("can_start") is True and dirty_state.get("safety_label") == "startable_unprotected_dirty") + check("repo.protected.blocked", protected_state.get("can_start") is False and protected_state.get("protected_dirty_count") == 1) + + blocked = False + try: + request_json( + "POST", + f"{base_url}/api/patch-swarm/runs", + { + "run_id": f"{run_id}-blocked", + "repo_path": str(protected_repo), + "task_brief": "This protected dirty repo must not start.", + "candidate_target": 10, + "mode": "fixture", + }, + ) + except ProductE2EError as exc: + blocked = "protected dirty paths" in str(exc) + check("repo.protected.create_blocked", blocked) + + before_create = app.patch_swarm_repo_snapshot(clean_repo) + detail = request_json( + "POST", + f"{base_url}/api/patch-swarm/runs", + { + "run_id": run_id, + "repo_path": str(clean_repo), + "task_brief": "Add one local fixture candidate note through Patch Swarm.", + "candidate_target": 10, + "max_parallel_agents": 2, + "providers": "codex-exec,claude-code,api-openai", + "mode": "fixture", + }, + ) + after_create = app.patch_swarm_repo_snapshot(clean_repo) + check("run.kind.product", detail.get("run_kind") == "product" and detail.get("run", {}).get("run_kind") == "product") + check("run.action_gates.initial", detail.get("action_gates", {}).get("can_approve") is True and detail.get("action_gates", {}).get("can_apply") is False) + check("run.create.no_selected_repo_mutation", before_create.get("fingerprint") == after_create.get("fingerprint")) + check("run.create.receipt", detail.get("no_mutation", {}).get("status") == "passed") + + selected_id = str((detail.get("integration", {}).get("selected_candidates") or [""])[0]) + reject_id = str((detail.get("candidates") or [{}])[-1].get("id") or "") + rejected = request_json("POST", f"{base_url}/api/patch-swarm/runs/{run_id}/reject", {"candidate_ids": [reject_id], "reason": "E2E reject path."}) + check("run.reject.receipt", any(item.get("id") == reject_id and item.get("decision") == "rejected" for item in rejected.get("candidates", []))) + + apply_before_approval_blocked = False + try: + request_json("POST", f"{base_url}/api/patch-swarm/runs/{run_id}/apply", {"limit": 1, "use_factory": True}) + except ProductE2EError as exc: + apply_before_approval_blocked = "approval required" in str(exc) + check("run.apply.requires_approval", apply_before_approval_blocked) + + approved = request_json("POST", f"{base_url}/api/patch-swarm/runs/{run_id}/approve", {"candidate_ids": [selected_id], "notes": "E2E approval."}) + check("run.approve.receipt", approved.get("approval", {}).get("status") == "approved" and approved.get("action_gates", {}).get("can_apply") is True) + + before_apply = app.patch_swarm_repo_snapshot(clean_repo) + applied = request_json("POST", f"{base_url}/api/patch-swarm/runs/{run_id}/apply", {"limit": 1, "validate_each": True, "use_factory": True}) + after_apply = app.patch_swarm_repo_snapshot(clean_repo) + apply_receipt = applied.get("apply_receipt", {}) + worktree = Path(str(apply_receipt.get("worktree") or "")) + check("run.apply.receipt", apply_receipt.get("status") == "applied" and apply_receipt.get("apply_scope") == "product_worktree_only") + check("run.apply.product_worktree", str(worktree).startswith(str(app.PATCH_SWARM_PRODUCT_WORKTREE_ROOT)) and worktree.exists()) + check("run.apply.no_selected_repo_mutation", before_apply.get("fingerprint") == after_apply.get("fingerprint") and applied.get("no_mutation", {}).get("status") == "passed") + check("run.apply.gate_closed", applied.get("action_gates", {}).get("can_apply") is False) + + screenshots = capture_screenshots(base_url, run_id, summary_dir) + for item in screenshots: + check(f"screenshot.{item['route']}.{item['viewport']['width']}x{item['viewport']['height']}.nonblank", bool(item.get("nonblank")), item.get("screenshot", "")) + check(f"screenshot.{item['route']}.{item['viewport']['width']}x{item['viewport']['height']}.no_overflow", bool(item.get("ui_checks", {}).get("no_horizontal_overflow")), item.get("screenshot", "")) + check(f"screenshot.{item['route']}.{item['viewport']['width']}x{item['viewport']['height']}.empty_state", bool(item.get("ui_checks", {}).get("empty_state_correct")), item.get("screenshot", "")) + check(f"screenshot.{item['route']}.{item['viewport']['width']}x{item['viewport']['height']}.stats_visibility", bool(item.get("ui_checks", {}).get("stats_visibility_correct")), item.get("screenshot", "")) + check(f"screenshot.{item['route']}.{item['viewport']['width']}x{item['viewport']['height']}.no_console_errors", not item.get("console_errors"), "; ".join(item.get("console_errors") or [])) + + status = "passed" if all(item["status"] == "passed" for item in checks) else "failed" + summary = { + "schema_version": "cento.patch_swarm.product_e2e_summary.v1", + "run_id": run_id, + "status": status, + "base_url": base_url, + "summary_dir": str(summary_dir.relative_to(ROOT)), + "repos": { + "clean": str(clean_repo), + "dirty_unprotected": str(dirty_repo), + "dirty_protected": str(protected_repo), + }, + "product_run_dir": str((pd.PATCH_SWARM_RUNS_ROOT / run_id).relative_to(ROOT)), + "product_worktree_root": str(app.PATCH_SWARM_PRODUCT_WORKTREE_ROOT.relative_to(ROOT)), + "checks": checks, + "screenshots": screenshots, + "written_at": datetime.now(timezone.utc).isoformat(), + } + write_json(summary_dir / "summary.json", summary) + print(json.dumps({"status": status, "run_id": run_id, "summary": str((summary_dir / "summary.json").relative_to(ROOT))}, indent=2)) + return 0 if status == "passed" else 1 + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/proreq_light.py b/scripts/proreq_light.py new file mode 100644 index 0000000..430d937 --- /dev/null +++ b/scripts/proreq_light.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Run the ProReq-light variant through Codex Exec instead of live Pro API.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import dev_pipeline_hard_proreq as hard + + +SCHEMA_CLOSED_LOOP_DELIVERY = "cento.proreq_light.closed_loop_delivery.v1" +SCHEMA_CLOSED_LOOP_VALIDATION = "cento.proreq_light.closed_loop_validation.v1" +SCHEMA_CLOSED_LOOP_INCIDENT = "cento.proreq_light.closed_loop_incident.v1" + + +def command_all(args: argparse.Namespace) -> int: + return hard.command_light_all(args) + + +def command_codex_plan(args: argparse.Namespace) -> int: + return hard.command_codex_pro_plan(args) + + +def run_command(command: list[str], *, timeout: int | None = None) -> dict[str, Any]: + started = hard.now_iso() + try: + result = subprocess.run( + command, + cwd=hard.ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + return { + "command": command, + "started_at": started, + "completed_at": hard.now_iso(), + "exit_code": result.returncode, + "stdout": result.stdout or "", + "stderr": result.stderr or "", + "status": "passed" if result.returncode == 0 else "failed", + } + except subprocess.TimeoutExpired as exc: + return { + "command": command, + "started_at": started, + "completed_at": hard.now_iso(), + "exit_code": None, + "stdout": exc.stdout if isinstance(exc.stdout, str) else "", + "stderr": exc.stderr if isinstance(exc.stderr, str) else "", + "status": "timeout", + "timeout_seconds": timeout, + } + + +def parse_stdout_json(result: dict[str, Any]) -> dict[str, Any]: + try: + payload = json.loads(str(result.get("stdout") or "{}")) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +def emit_json(args: argparse.Namespace, payload: dict[str, Any]) -> None: + if bool(getattr(args, "json", False)): + print(json.dumps(payload, indent=2, sort_keys=False)) + + +def run_artifact_path(name: str) -> Path | None: + current, latest = hard.artifact_dirs() + for candidate in (current / name, latest / name): + if candidate.exists(): + return candidate + return None + + +def current_workset_path() -> Path | None: + return run_artifact_path("parallel_patch_workset.json") + + +def ensure_light_planning(args: argparse.Namespace) -> int: + if bool(getattr(args, "fresh", False)) or current_workset_path() is None: + return command_all(args) + return 0 + + +def write_incident( + *, + incident_type: str, + summary: str, + failed_command: list[str] | None, + details: dict[str, Any], +) -> dict[str, str]: + payload = { + "schema_version": SCHEMA_CLOSED_LOOP_INCIDENT, + "run_id": hard.run_id(), + "status": "blocked", + "incident_type": incident_type, + "summary": summary, + "failed_command": failed_command or [], + "details": details, + } + json_rel = hard.write_run_artifact("closed_loop_incident.json", payload) + md = "\n".join( + [ + f"# ProReq-light Closed-Loop Incident: {incident_type}", + "", + f"- run_id: `{hard.run_id()}`", + f"- status: `blocked`", + f"- summary: {summary}", + f"- failed_command: `{ ' '.join(failed_command or []) }`", + "", + "## Details", + "", + "```json", + json.dumps(details, indent=2, sort_keys=True), + "```", + "", + "## Recovery", + "", + "Repair the underlying failure, then rerun `cento proreq-light deliver --fresh --json` or rerun without `--fresh` to reuse the existing ProReq-light workset.", + ] + ) + md_rel = hard.write_run_text("closed_loop_incident.md", md + "\n") + return {"incident": json_rel, "incident_markdown": md_rel} + + +def write_validation(args: argparse.Namespace, workset_path: Path, *, delivery_status: str) -> tuple[str, dict[str, Any]]: + if delivery_status != "completed": + payload = { + "schema_version": SCHEMA_CLOSED_LOOP_VALIDATION, + "run_id": hard.run_id(), + "status": "skipped", + "delivery_status": delivery_status, + "commands": [], + "reason": "Final validation runs only after closed-loop delivery completes.", + } + return hard.write_run_artifact("closed_loop_validation.json", payload), payload + story_index_path = run_artifact_path("story_index.json") or hard.artifact_dirs()[0] / "story_index.json" + commands: list[list[str]] = [ + [sys.executable, "-m", "json.tool", str(story_index_path)], + [sys.executable, "-m", "json.tool", str(workset_path)], + [sys.executable, str(hard.ROOT / "scripts" / "cento_workset.py"), "check", hard.rel(workset_path), "--allow-creates", "--json"], + ] + if bool(getattr(args, "full_check", True)): + commands.append(["make", "check"]) + results = [run_command(command, timeout=int(getattr(args, "validation_timeout", 600))) for command in commands] + failed = [item for item in results if item.get("exit_code") != 0] + status = "skipped" if delivery_status != "completed" else ("passed" if not failed else "failed") + payload = { + "schema_version": SCHEMA_CLOSED_LOOP_VALIDATION, + "run_id": hard.run_id(), + "status": status, + "delivery_status": delivery_status, + "commands": [ + { + "command": item["command"], + "exit_code": item.get("exit_code"), + "status": item.get("status"), + "stdout_tail": str(item.get("stdout") or "")[-2000:], + "stderr_tail": str(item.get("stderr") or "")[-2000:], + } + for item in results + ], + } + return hard.write_run_artifact("closed_loop_validation.json", payload), payload + + +def write_evidence(delivery: dict[str, Any], validation: dict[str, Any], incident_paths: dict[str, str] | None = None) -> str: + incident_paths = incident_paths or {} + payload = { + "schema_version": "cento.proreq_light.closed_loop_evidence.v1", + "run_id": hard.run_id(), + "status": delivery.get("status"), + "delivery": delivery, + "validation": validation, + "incident": incident_paths, + } + json_rel = hard.write_run_artifact("closed_loop_evidence.json", payload) + receipt = str(delivery.get("workset_receipt") or "") + changed = ", ".join([str(item) for item in delivery.get("changed_paths") or []]) or "none" + md = "\n".join( + [ + "# ProReq-light Closed-Loop Evidence", + "", + f"- run_id: `{hard.run_id()}`", + f"- status: `{delivery.get('status')}`", + f"- runtime: `{delivery.get('runtime_profile')}`", + f"- workset_receipt: `{receipt}`", + f"- changed_paths: {changed}", + f"- validation: `{validation.get('status')}`", + f"- evidence_json: `{json_rel}`", + ] + ) + if incident_paths: + md += f"\n- incident: `{incident_paths.get('incident_markdown')}`\n" + return hard.write_run_text("closed_loop_evidence.md", md + "\n") + + +def command_deliver(args: argparse.Namespace) -> int: + planning_code = ensure_light_planning(args) + if planning_code: + incident_paths = write_incident( + incident_type="planning_failed", + summary="ProReq-light planning failed before worker dispatch.", + failed_command=["proreq-light", "all"], + details={"exit_code": planning_code}, + ) + delivery = { + "schema_version": SCHEMA_CLOSED_LOOP_DELIVERY, + "run_id": hard.run_id(), + "status": "blocked", + "stage": "planning", + **incident_paths, + } + validation_rel, validation = write_validation(args, hard.artifact_dirs()[0] / "parallel_patch_workset.json", delivery_status="blocked") + delivery["validation"] = validation_rel + write_evidence(delivery, validation, incident_paths) + hard.write_run_artifact("closed_loop_delivery.json", delivery) + emit_json(args, delivery) + return 1 + + workset_path = current_workset_path() + if workset_path is None: + incident_paths = write_incident( + incident_type="missing_workset", + summary="ProReq-light did not produce parallel_patch_workset.json.", + failed_command=None, + details={}, + ) + delivery = { + "schema_version": SCHEMA_CLOSED_LOOP_DELIVERY, + "run_id": hard.run_id(), + "status": "blocked", + "stage": "preflight", + **incident_paths, + } + validation_rel, validation = write_validation(args, hard.artifact_dirs()[0] / "parallel_patch_workset.json", delivery_status="blocked") + delivery["validation"] = validation_rel + write_evidence(delivery, validation, incident_paths) + hard.write_run_artifact("closed_loop_delivery.json", delivery) + emit_json(args, delivery) + return 1 + + check_command = [ + sys.executable, + str(hard.ROOT / "scripts" / "cento_workset.py"), + "check", + hard.rel(workset_path), + "--allow-creates", + "--json", + ] + check_result = run_command(check_command, timeout=60) + check_stdout_rel = hard.write_run_text("closed_loop_check_stdout.txt", str(check_result.get("stdout") or "")) + check_stderr_rel = hard.write_run_text("closed_loop_check_stderr.txt", str(check_result.get("stderr") or "")) + check_payload = parse_stdout_json(check_result) + if check_result.get("exit_code") != 0: + incident_paths = write_incident( + incident_type="workset_preflight_failed", + summary="ProReq-light workset failed preflight.", + failed_command=check_command, + details={"check": check_payload, "stderr": check_result.get("stderr", "")[-4000:]}, + ) + delivery = { + "schema_version": SCHEMA_CLOSED_LOOP_DELIVERY, + "run_id": hard.run_id(), + "status": "blocked", + "stage": "preflight", + "workset": hard.rel(workset_path), + "preflight": check_payload, + "preflight_stdout": check_stdout_rel, + "preflight_stderr": check_stderr_rel, + **incident_paths, + } + validation_rel, validation = write_validation(args, workset_path, delivery_status="blocked") + delivery["validation"] = validation_rel + write_evidence(delivery, validation, incident_paths) + hard.write_run_artifact("closed_loop_delivery.json", delivery) + emit_json(args, delivery) + return 1 + + if bool(getattr(args, "plan_only", False)): + delivery = { + "schema_version": SCHEMA_CLOSED_LOOP_DELIVERY, + "run_id": hard.run_id(), + "status": "plan-only", + "stage": "ready", + "workset": hard.rel(workset_path), + "preflight": check_payload, + "preflight_stdout": check_stdout_rel, + "preflight_stderr": check_stderr_rel, + "runtime_profile": args.runtime_profile, + } + validation_rel, validation = write_validation(args, workset_path, delivery_status="plan-only") + delivery["validation"] = validation_rel + write_evidence(delivery, validation) + hard.write_run_artifact("closed_loop_delivery.json", delivery) + emit_json(args, delivery) + return 0 + + execute_command = [ + sys.executable, + str(hard.ROOT / "scripts" / "cento_workset.py"), + "execute", + hard.rel(workset_path), + "--runtime", + "local-command", + "--runtime-profile", + args.runtime_profile, + "--max-parallel", + str(args.max_parallel), + "--integrate", + "sequential", + "--validation", + args.validation, + "--worker-timeout", + str(args.worker_timeout), + "--allow-creates", + "--json", + ] + if not bool(getattr(args, "no_apply", False)): + execute_command.append("--apply") + execute_result = run_command(execute_command, timeout=int(args.delivery_timeout)) + stdout_rel = hard.write_run_text("closed_loop_workset_stdout.txt", str(execute_result.get("stdout") or "")) + stderr_rel = hard.write_run_text("closed_loop_workset_stderr.txt", str(execute_result.get("stderr") or "")) + execute_payload = parse_stdout_json(execute_result) + receipt_rel = str(execute_payload.get("workset_receipt") or "") + receipt = hard.read_json(hard.ROOT / receipt_rel) if receipt_rel else {} + delivery_status = "completed" if execute_result.get("exit_code") == 0 and str(receipt.get("status") or execute_payload.get("status")) == "completed" else "blocked" + validation_rel, validation = write_validation(args, workset_path, delivery_status=delivery_status) + incident_paths: dict[str, str] = {} + if delivery_status == "completed" and validation.get("status") != "passed": + delivery_status = "blocked" + incident_paths = write_incident( + incident_type="validation_failed", + summary="Closed-loop worker delivery completed but final validation failed.", + failed_command=None, + details={"validation": validation}, + ) + elif delivery_status != "completed": + incident_paths = write_incident( + incident_type="workset_delivery_blocked", + summary="Codex worker delivery did not complete cleanly.", + failed_command=execute_command, + details={ + "exit_code": execute_result.get("exit_code"), + "stderr": str(execute_result.get("stderr") or "")[-4000:], + "result": execute_payload, + "receipt": receipt, + }, + ) + delivery = { + "schema_version": SCHEMA_CLOSED_LOOP_DELIVERY, + "run_id": hard.run_id(), + "status": delivery_status, + "stage": "handoff" if delivery_status == "completed" else "blocked", + "workset": hard.rel(workset_path), + "runtime": "local-command", + "runtime_profile": args.runtime_profile, + "max_parallel": args.max_parallel, + "apply": "none" if bool(getattr(args, "no_apply", False)) else "clean", + "preflight": check_payload, + "preflight_stdout": check_stdout_rel, + "preflight_stderr": check_stderr_rel, + "workset_result": execute_payload, + "workset_receipt": receipt_rel, + "workset_stdout": stdout_rel, + "workset_stderr": stderr_rel, + "changed_paths": [str(item) for item in receipt.get("changed_paths", []) if isinstance(item, str)], + "validation": validation_rel, + "cost_policy": "ProReq-light + Codex Exec local workers; no Hard Pro, image API, or OpenAI API workers.", + **incident_paths, + } + hard.write_run_artifact("closed_loop_delivery.json", delivery) + write_evidence(delivery, validation, incident_paths) + emit_json(args, delivery) + return 0 if delivery_status == "completed" else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate ProReq-light artifacts with Codex Exec planning.") + sub = parser.add_subparsers(dest="command", required=True) + commands = { + "intake": hard.command_intake, + "context": hard.command_context, + "screenshot": hard.command_light_screenshot, + "pro-request": hard.command_pro_request, + "codex-plan": command_codex_plan, + "pro-plan": command_codex_plan, + "backend-work": hard.command_backend_work, + "integration-plan": hard.command_integration, + "validation-plan": hard.command_validation, + "evidence": hard.command_evidence, + "all": command_all, + } + for name, func in commands.items(): + item = sub.add_parser(name) + item.set_defaults(func=func) + deliver = sub.add_parser("deliver", help="Run ProReq-light planning through Codex worker patch delivery.") + deliver.add_argument("--fresh", action="store_true", help="Regenerate ProReq-light artifacts before worker dispatch.") + deliver.add_argument("--plan-only", action="store_true", help="Stop after planning and workset preflight.") + deliver.add_argument("--no-apply", action="store_true", help="Collect and integrate bundles without applying accepted patches.") + deliver.add_argument("--runtime-profile", default="codex-fast", help="Named local runtime profile for Codex workers.") + deliver.add_argument("--max-parallel", type=int, default=3, help="Maximum parallel Codex workers.") + deliver.add_argument("--validation", default="smoke", help="Validation tier for generated build manifests.") + deliver.add_argument("--worker-timeout", type=int, default=180, help="Per-worker timeout in seconds.") + deliver.add_argument("--delivery-timeout", type=int, default=1800, help="Whole workset execution timeout in seconds.") + deliver.add_argument("--validation-timeout", type=int, default=600, help="Timeout for each final validation command.") + deliver.add_argument("--full-check", dest="full_check", action="store_true", default=True, help="Run make check after worker delivery.") + deliver.add_argument("--no-full-check", dest="full_check", action="store_false", help="Skip make check in final validation.") + deliver.add_argument("--json", action="store_true", help="Print closed-loop delivery JSON.") + deliver.set_defaults(func=command_deliver) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/proreq_parallel_roadmap.py b/scripts/proreq_parallel_roadmap.py new file mode 100644 index 0000000..07bcec6 --- /dev/null +++ b/scripts/proreq_parallel_roadmap.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +"""Coordinate multiple Hard ProReq passes into the parallel delivery roadmap.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator + + +ROOT = Path(__file__).resolve().parents[1] +PIPELINE_ROOT = ROOT / "workspace" / "runs" / "dev-pipeline-studio" / "docs-pages" / "latest" +ROADMAP_PATH = ROOT / "docs" / "parallel-ai-delivery-roadmap.md" + +sys.path.insert(0, str(ROOT / "scripts")) +import agent_work_app as app # noqa: E402 + + +BASE_VISION = ( + "Build the next big Cento delivery system: parse requirements once into exclusive " + "parallel workstreams, run 10 AI workers to produce structured patch/artifact outputs, " + "then converge through 2-3 integrator/validator lanes where integration and validation " + "are deterministic first and AI is called only when deterministic gates cannot classify " + "a conflict, missing evidence, or ambiguity. The target is 2-3 minutes instead of 10 " + "minutes, with only $3-5 marginal AI cost." +) + + +PASS_SPECS: list[dict[str, str]] = [ + { + "id": "architecture-roadmap", + "title": "Architecture Roadmap", + "operator_prompt": ( + f"{BASE_VISION}\n\n" + "Focus this ProReq pass on the E2E architecture, runtime components, worker " + "contracts, manifest flow, budget model, latency model, rollout phases, and the " + "minimum changes needed to make the existing Cento foundation deliver this." + ), + "image_task": ( + "Create a product UI screenshot prompt for an architecture roadmap view showing " + "requirements intake, planner, 10 parallel worker lanes, 2-3 integrator/validator " + "lanes, deterministic gates, AI fallback only-if-needed, cost, and timing receipts." + ), + }, + { + "id": "integration-validation-manifests", + "title": "Integration And Validation Manifests", + "operator_prompt": ( + f"{BASE_VISION}\n\n" + "Focus this ProReq pass on the manifests and policies for integration and validation: " + "story manifests, workset manifests, integration manifests, validation manifests, " + "receipt schemas, fallback trigger packets, quarantine behavior, and rollback evidence." + ), + "image_task": ( + "Create a product UI screenshot prompt for the integrator/validator control surface: " + "patch safety lane, focused test lane, release evidence lane, quarantine queue, rollback " + "receipt, AI reviewer call only when deterministic validation cannot decide." + ), + }, + { + "id": "operator-image-and-flow", + "title": "Operator Image And Flow", + "operator_prompt": ( + f"{BASE_VISION}\n\n" + "Focus this ProReq pass on the operator experience and image prompt: the Dev Pipeline " + "or Factory screen should show the request being split, 10 workers running, 2-3 " + "validators converging, deterministic gates passing or quarantining outputs, and a " + "clear final roadmap/release packet." + ), + "image_task": ( + "Generate the strongest ChatGPT image prompt for an in-app screenshot of the full " + "parallel delivery cockpit: dense operational UI, worker lanes, validator lanes, " + "cost/timing counters, fallback AI review marker, and final evidence handoff." + ), + }, +] + + +def now_stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + if isinstance(payload, dict): + return payload + return {} + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +@contextmanager +def scoped_env(updates: dict[str, str]) -> Iterator[None]: + old_values = {key: os.environ.get(key) for key in updates} + try: + for key, value in updates.items(): + os.environ[key] = value + yield + finally: + for key, value in old_values.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def pipeline_payload(operator_prompt: str, reference_screenshot: str) -> dict[str, Any]: + screenshot_input: dict[str, Any] = {"id": "ui-screenshot-request", "kind": "image", "source": "auto"} + if reference_screenshot: + screenshot_input["image_refs"] = [reference_screenshot] + screenshot_input["image_notes"] = "Use this as visual style context for the requested delivery cockpit image." + return { + "schema_version": app.PIPELINE_RUN_SCHEMA_VERSION, + "project_id": app.HARD_PROREQ_PROJECT_ID, + "template_id": app.HARD_PROREQ_TEMPLATE_ID, + "inputs": [ + {"id": "operator-thoughts", "kind": "questionnaire", "source": "user", "answer": operator_prompt}, + {"id": "generated-cento-context", "kind": "path", "source": "auto"}, + screenshot_input, + {"id": "pro-backend-schema", "kind": "details", "source": "auto"}, + {"id": "backend-work-handoff", "kind": "evidence", "source": "auto"}, + ], + } + + +def run_payload_path(run_id: str) -> Path: + return PIPELINE_ROOT / "execution" / "runs" / f"{run_id}.json" + + +def run_payload(run_id: str) -> dict[str, Any]: + return read_json(run_payload_path(run_id)) or app.dev_pipeline_artifact_json( + app.DEV_PIPELINE_STUDIO_ROOT, + "execution/execution_run.json", + ) + + +def artifact_root(run_id: str) -> Path: + return PIPELINE_ROOT / "execution" / "hard-proreq" / run_id + + +def wait_for_run(run_id: str, timeout_seconds: int, poll_seconds: float) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + latest: dict[str, Any] = {} + while time.monotonic() < deadline: + latest = run_payload(run_id) + status = str(latest.get("status") or "") + if status in {"completed", "failed", "blocked", "rejected"}: + return latest + time.sleep(poll_seconds) + latest = run_payload(run_id) + latest["observed_status"] = str(latest.get("status") or "") + latest["status"] = "timeout" + latest["timeout_seconds"] = timeout_seconds + return latest + + +def run_workset_check(workset_path: str) -> dict[str, Any]: + if not workset_path: + return {"status": "missing", "command": [], "exit_code": 1, "stdout": "", "stderr": "missing workset path"} + command = ["./scripts/cento.sh", "workset", "check", workset_path] + result = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return { + "status": "passed" if result.returncode == 0 else "failed", + "command": command, + "exit_code": result.returncode, + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + } + + +def summarize_artifacts(run_id: str) -> dict[str, Any]: + root = artifact_root(run_id) + backend = read_json(root / "backend_work_manifest.json") + pro_response = read_json(root / "pro_backend_response.json") + image_response = read_json(root / "image_generation_response.json") + image_request = read_json(root / "image_generation_request.json") + integration = read_json(root / "integration_plan.json") + validation = read_json(root / "validation_plan.json") + evidence = read_json(root / "hard_proreq_evidence.json") + story_index = read_json(root / "story_index.json") + workset_path = str(backend.get("parallel_patch_workset") or "") + image_error = "" + response_payload = image_response.get("response") + if isinstance(response_payload, dict): + error_payload = response_payload.get("error") + if isinstance(error_payload, dict): + image_error = str(error_payload.get("message") or "") + return { + "artifact_root": rel(root), + "story_count": int(backend.get("story_count") or story_index.get("story_count") or 0), + "parallel_patch_workset": workset_path, + "integration_policy": str(backend.get("integration_policy") or ""), + "pro_response_status": str(pro_response.get("status") or ""), + "pro_skip_code": str(pro_response.get("skip_code") or ""), + "pro_model": str(pro_response.get("model") or ""), + "image_response_status": str(image_response.get("status") or ""), + "image_skip_code": str(image_response.get("skip_code") or ""), + "image_error": image_error, + "image_model": str(image_response.get("model") or image_request.get("model") or ""), + "image_request": rel(root / "image_generation_request.json"), + "generated_image": str(image_response.get("output_image") or ""), + "integration_steps": [str(item) for item in integration.get("steps", []) if isinstance(item, str)], + "validation_commands": [str(item) for item in validation.get("commands", []) if isinstance(item, str)], + "evidence_status": str(evidence.get("status") or ""), + } + + +def run_one_pass(spec: dict[str, str], args: argparse.Namespace) -> dict[str, Any]: + env_updates = { + "CENTO_HARD_PROREQ_IMAGE_TASK": spec["image_task"], + "CENTO_HARD_PROREQ_STEP_TIMEOUT": str(args.step_timeout), + "CENTO_HARD_PROREQ_PRO_TIMEOUT": str(args.pro_timeout), + "CENTO_HARD_PROREQ_IMAGE_TIMEOUT": str(args.image_timeout), + } + if args.live_pro and os.environ.get("OPENAI_API_KEY"): + env_updates["CENTO_HARD_PROREQ_DISPATCH_PRO"] = "1" + if args.reference_screenshot: + env_updates["CENTO_HARD_PROREQ_REFERENCE_SCREENSHOT"] = args.reference_screenshot + with scoped_env(env_updates): + response = app.dev_pipeline_start_pipeline_run( + pipeline_payload(spec["operator_prompt"], args.reference_screenshot), + spawn=False, + ) + run_id = str(response.get("run_id") or "") + app.dev_pipeline_spawn_execution_e2e( + app.DEV_PIPELINE_STUDIO_ROOT, + app.HARD_PROREQ_PROJECT_ID, + app.HARD_PROREQ_TEMPLATE_ID, + run_id, + ) + final_payload = wait_for_run(run_id, args.per_run_timeout, args.poll_seconds) + artifacts = summarize_artifacts(run_id) + workset_check = run_workset_check(str(artifacts.get("parallel_patch_workset") or "")) + return { + "id": spec["id"], + "title": spec["title"], + "run_id": run_id, + "status": str(final_payload.get("status") or ""), + "duration_seconds": int(final_payload.get("duration_seconds") or 0), + "started_at": str(final_payload.get("started_at") or ""), + "finished_at": str(final_payload.get("finished_at") or ""), + "operator_prompt": spec["operator_prompt"], + "image_task": spec["image_task"], + "artifacts": artifacts, + "workset_check": workset_check, + "steps": [ + { + "id": str(step.get("id") or ""), + "status": str(step.get("status") or ""), + "exit_code": step.get("exit_code"), + } + for step in final_payload.get("steps", []) + if isinstance(step, dict) + ], + } + + +def roadmap_lines(runs: list[dict[str, Any]], receipt_path: Path) -> list[str]: + completed = [run for run in runs if run.get("status") == "completed"] + pro_statuses = sorted({str(run.get("artifacts", {}).get("pro_response_status") or "unknown") for run in runs}) + image_statuses = sorted({str(run.get("artifacts", {}).get("image_response_status") or "unknown") for run in runs}) + run_table = [ + "| Pass | Status | Stories | Workset | Pro | Image |", + "| --- | --- | ---: | --- | --- | --- |", + ] + for run in runs: + artifacts = run.get("artifacts", {}) if isinstance(run.get("artifacts"), dict) else {} + pro = artifacts.get("pro_response_status") or "unknown" + if artifacts.get("pro_skip_code"): + pro = f"{pro} ({artifacts.get('pro_skip_code')})" + image = artifacts.get("image_response_status") or "unknown" + if artifacts.get("image_skip_code"): + image = f"{image} ({artifacts.get('image_skip_code')})" + elif artifacts.get("image_error"): + image_error = str(artifacts.get("image_error") or "") + if "must be verified" in image_error: + image = f"{image} (organization verification required)" + else: + image = f"{image} ({image_error[:80]})" + run_table.append( + "| {title} | {status} | {stories} | `{workset}` | {pro} | {image} |".format( + title=run.get("title"), + status=run.get("status"), + stories=artifacts.get("story_count") or 0, + workset=artifacts.get("parallel_patch_workset") or "", + pro=pro, + image=image, + ) + ) + return [ + "# Parallel AI Delivery Roadmap", + "", + f"Generated by `scripts/proreq_parallel_roadmap.py` from {len(runs)} Hard ProReq passes.", + f"Coordination receipt: `{rel(receipt_path)}`.", + "", + "## Objective", + "", + "Build the next Cento delivery layer: one requirements pass decomposes a feature into exclusive workstreams, 10 workers produce structured patch or artifact outputs in parallel, 2-3 integrator/validator lanes converge the results, and deterministic integration decides most outcomes before any extra model review is called. The target operator experience is task completion in 2-3 minutes instead of roughly 10 minutes, with only about $3-5 marginal AI cost for fanout and fallback review.", + "", + "## ProReq Evidence", + "", + *run_table, + "", + f"Completed passes: {len(completed)}/{len(runs)}. Pro response states: {', '.join(pro_statuses)}. Image response states: {', '.join(image_statuses)}.", + "", + "## Target Architecture", + "", + "1. Intake turns operator notes into a strict requirements packet: goal, acceptance checks, read context, owned path candidates, risk limits, budget, and validation mode.", + "2. Planning creates 8-12 workstreams, defaulting to 10, and rejects overlapping write paths unless the overlap is moved into an explicit serialized integrator task.", + "3. Worker fanout runs up to 10 structured workers through `cento workset execute`; workers return patch proposals or artifacts and never mutate repo files directly.", + "4. Integration/validation runs as 2-3 deterministic lanes: patch ownership and apply checks, focused tests/UI or artifact checks, and release evidence/rollback checks.", + "5. AI fallback is called only for unresolved ambiguity, failed deterministic validation that needs diagnosis, or conflict review, using a compact failure packet and a cheap reviewer profile such as `api-mini-integrator`.", + "6. Handoff writes one release packet with applied patches, rejected patches, validation receipts, cost receipt, timings, rollback plan, and residual risks.", + "", + "## Implementation Roadmap", + "", + "M1: Make ProReq output directly executable by the workset layer. The generated 10-story handoff must become a checked `cento.workset.v1` manifest with `max_parallel: 10`, per-task cost estimates, and validation commands.", + "", + "M2: Add the integrator/validator pool without changing the worker contract. Start with three deterministic lanes: patch safety, focused validation, and release evidence. Independent failures are quarantined without blocking unrelated accepted patches.", + "", + "M3: Add only-if-needed AI review. Clean runs use zero reviewer calls after planning. Conflicted fixtures produce exactly one bounded reviewer artifact, then return to deterministic patch and receipt handling.", + "", + "M4: Benchmark speed and cost with 1, 3, 5, and 10 workers. Track wall-clock time, queue delay, integration time, model calls, and estimated cost until medium scoped tasks land in the 2-3 minute and $3-5 marginal range.", + "", + "M5: Expose the flow in Dev Pipeline Studio or Factory as `Run Parallel Delivery`: live worker lanes, integrator lanes, deterministic gates, fallback review calls, cost, and evidence receipts in one execution view.", + "", + "## Acceptance Metrics", + "", + "- 10 worker lanes can run concurrently when write paths are exclusive.", + "- 2-3 integrator/validator lanes classify clean, failed, and conflicted outputs deterministically.", + "- Clean runs complete without AI review after initial planning.", + "- Conflicted runs call AI only with a compact failure packet and a hard budget ceiling.", + "- Typical end-to-end completion time is 2-3 minutes for medium scoped tasks.", + "- Marginal fanout and fallback cost stays near $3-5, with a hard stop before budget overrun.", + "- Every run leaves receipts for worker outputs, integration decisions, validation, rollback, cost, and final handoff.", + "", + "## Risks", + "", + "Shared-file pressure is the main design risk. The system should not hide shared file edits inside parallel workers; it should emit a serialized integrator task. Validation latency is the second risk, so the next implementation needs narrow validation selection before increasing fanout. The third risk is model drift during fallback review; fallback output remains advisory unless it is converted into the same deterministic patch and receipt contract as worker output.", + ] + + +def validate_roadmap(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") if path.exists() else "" + required = [ + "10 workers", + "2-3 integrator", + "2-3 minutes", + "$3-5", + "deterministic", + "AI fallback", + "only", + "image", + ] + missing = [item for item in required if item.lower() not in text.lower()] + return {"status": "passed" if not missing else "failed", "missing": missing} + + +def command_run(args: argparse.Namespace) -> int: + run_dir = ROOT / "workspace" / "runs" / "proreq-roadmap" / now_stamp() + run_dir.mkdir(parents=True, exist_ok=True) + runs: list[dict[str, Any]] = [] + for index, spec in enumerate(PASS_SPECS): + print(f"starting {spec['id']}", flush=True) + record = run_one_pass(spec, args) + runs.append(record) + write_json(run_dir / "coordination_receipt.partial.json", {"schema_version": "cento.proreq_roadmap_receipt.v1", "runs": runs}) + print(f"finished {spec['id']} status={record['status']} run_id={record['run_id']}", flush=True) + if index < len(PASS_SPECS) - 1 and args.sleep_seconds > 0: + time.sleep(args.sleep_seconds) + receipt_path = run_dir / "coordination_receipt.json" + ROADMAP_PATH.parent.mkdir(parents=True, exist_ok=True) + ROADMAP_PATH.write_text("\n".join(roadmap_lines(runs, receipt_path)) + "\n", encoding="utf-8") + roadmap_check = validate_roadmap(ROADMAP_PATH) + worksets_ok = all( + isinstance(run.get("workset_check"), dict) and run["workset_check"].get("status") == "passed" + for run in runs + ) + receipt = { + "schema_version": "cento.proreq_roadmap_receipt.v1", + "written_at": now_iso(), + "status": "completed" if all(run.get("status") == "completed" for run in runs) and worksets_ok and roadmap_check["status"] == "passed" else "failed", + "roadmap": rel(ROADMAP_PATH), + "roadmap_check": roadmap_check, + "live_policy": { + "openai_api_key_present": bool(os.environ.get("OPENAI_API_KEY")), + "live_pro_requested": bool(args.live_pro), + "reference_screenshot": args.reference_screenshot, + }, + "runs": runs, + } + write_json(receipt_path, receipt) + write_json(run_dir / "coordination_receipt.partial.json", receipt) + print(json.dumps({"receipt": rel(receipt_path), "roadmap": rel(ROADMAP_PATH), "status": receipt["status"]}, indent=2), flush=True) + return 0 if receipt["status"] == "completed" else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run three Hard ProReq passes and synthesize the parallel AI delivery roadmap.") + parser.add_argument("--sleep-seconds", type=float, default=10.0, help="Sleep between ProReq passes.") + parser.add_argument("--poll-seconds", type=float, default=3.0, help="Polling interval while a pass is running.") + parser.add_argument("--per-run-timeout", type=int, default=600, help="Timeout per ProReq pass in seconds.") + parser.add_argument("--step-timeout", type=int, default=360, help="Hard ProReq subprocess timeout in seconds.") + parser.add_argument("--pro-timeout", type=int, default=360, help="Live Pro request timeout in seconds.") + parser.add_argument("--image-timeout", type=int, default=360, help="Live image request timeout in seconds.") + parser.add_argument("--reference-screenshot", default="", help="Optional repo-relative or absolute screenshot path for the image lane.") + parser.add_argument("--live-pro", action="store_true", default=True, help="Enable live Pro dispatch when OPENAI_API_KEY is present.") + parser.add_argument("--no-live-pro", action="store_false", dest="live_pro", help="Do not set CENTO_HARD_PROREQ_DISPATCH_PRO automatically.") + parser.set_defaults(func=command_run) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/restart_discord.sh b/scripts/restart_discord.sh index 614699d..5289164 100755 --- a/scripts/restart_discord.sh +++ b/scripts/restart_discord.sh @@ -7,29 +7,72 @@ source "$SCRIPT_DIR/lib/common.sh" usage() { cat <<'USAGE' -Usage: restart_discord.sh +Usage: + restart_discord.sh rerun + restart_discord.sh update [--rerun] + restart_discord.sh status -Restart Discord by terminating the current desktop process and launching it -again through the first available launcher: discord, Discord, flatpak, or snap. +Restart Discord, install the latest official Linux tarball into the user +profile, or show the current launcher/process state. + +Cento commands: + cento discord rerun + cento discord update + cento discord update --rerun + cento discord status + cento rd USAGE } -while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help) - usage - exit 0 - ;; - *) - cento_die "Unknown argument: $1" - ;; - esac -done +ACTION=${1:-rerun} +if [[ $# -gt 0 ]]; then + shift +fi + +case "$ACTION" in + -h|--help) + usage + exit 0 + ;; + restart) + ACTION=rerun + ;; +esac -DISCORD_PROCESS_PATTERN='(^|/)(Discord|discord)( |$)|app/com\.discordapp\.Discord' +DISCORD_DOWNLOAD_URL=${CENTO_DISCORD_DOWNLOAD_URL:-https://discord.com/api/download?platform=linux&format=tar.gz} +DISCORD_UPDATES_URL=${CENTO_DISCORD_UPDATES_URL:-https://updates.discord.com/} +DISCORD_INSTALL_DIR=${CENTO_DISCORD_HOME:-$HOME/.local/opt/Discord} +DISCORD_CONFIG_DIR=${CENTO_DISCORD_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/discord} +DISCORD_LOG_DIR=${CENTO_DISCORD_LOG_DIR:-$SCRIPT_DIR/../workspace/runs/discord} +DISCORD_START_WAIT=${CENTO_DISCORD_START_WAIT:-5} + +require_linux() { + [[ "$(uname -s)" == "Linux" ]] || cento_die "Discord control is only supported on Linux." +} + +discord_pids() { + { + pgrep -x Discord 2>/dev/null || true + pgrep -x discord 2>/dev/null || true + pgrep -f 'app/com\.discordapp\.Discord|com\.discordapp\.Discord' 2>/dev/null || true + pgrep -f '/discord/updater_bootstrap|/Discord/updater_bootstrap' 2>/dev/null || true + pgrep -f 'zenity --progress --text=Downloading Discord' 2>/dev/null || true + } | sort -u +} discord_running() { - pgrep -f "$DISCORD_PROCESS_PATTERN" >/dev/null 2>&1 + discord_pids | grep -q . +} + +discord_app_pids() { + { + pgrep -x Discord 2>/dev/null || true + pgrep -f 'app/com\.discordapp\.Discord|com\.discordapp\.Discord' 2>/dev/null || true + } | sort -u +} + +discord_app_running() { + discord_app_pids | grep -q . } stop_discord() { @@ -38,33 +81,264 @@ stop_discord() { return 0 fi + local pids + pids=$(discord_pids | tr '\n' ' ') cento_info "Stopping Discord..." - pkill -TERM -f "$DISCORD_PROCESS_PATTERN" || true + # Prefer exact process-name matches. Broad -f matching can hit the wrapper. + kill $pids 2>/dev/null || true sleep 2 if discord_running; then cento_warn "Discord did not exit after 2s; killing remaining processes." - pkill -KILL -f "$DISCORD_PROCESS_PATTERN" || true + pids=$(discord_pids | tr '\n' ' ') + kill -9 $pids 2>/dev/null || true + sleep 1 fi } -launch_discord() { - cento_info "Starting Discord..." +launch_user_local_discord() { + local log_file=$1 + local executable + executable=$(prepare_user_local_discord "$log_file") || return 1 + cento_info "Starting Discord from $executable" + setsid "$executable" --no-sandbox >"$log_file" 2>&1 < /dev/null & + return 0 +} +launch_system_discord() { + local log_file=$1 if cento_have_cmd discord; then - setsid discord >/dev/null 2>&1 & + cento_info "Starting Discord with $(command -v discord)" + setsid discord >"$log_file" 2>&1 < /dev/null & elif cento_have_cmd Discord; then - setsid Discord >/dev/null 2>&1 & + cento_info "Starting Discord with $(command -v Discord)" + setsid Discord >"$log_file" 2>&1 < /dev/null & elif cento_have_cmd flatpak && flatpak info com.discordapp.Discord >/dev/null 2>&1; then - setsid flatpak run com.discordapp.Discord >/dev/null 2>&1 & + cento_info "Starting Discord with Flatpak" + setsid flatpak run com.discordapp.Discord >"$log_file" 2>&1 < /dev/null & elif cento_have_cmd snap && snap list discord >/dev/null 2>&1; then - setsid snap run discord >/dev/null 2>&1 & + cento_info "Starting Discord with Snap" + setsid snap run discord >"$log_file" 2>&1 < /dev/null & + else + return 1 + fi +} + +latest_log_path() { + cento_ensure_dir "$DISCORD_LOG_DIR" + printf '%s/rerun-%s.log\n' "$DISCORD_LOG_DIR" "$(cento_timestamp)" +} + +wait_for_discord() { + local log_file=$1 + sleep "$DISCORD_START_WAIT" + if discord_app_running; then + cento_info "Discord is running." + discord_app_pids | sed 's/^/[INFO] pid: /' >&2 + return 0 + fi + + cento_warn "Discord did not stay running after ${DISCORD_START_WAIT}s." + if [[ -f "$log_file" ]] && grep -Eiq 'update-manually|Host update is available|Manual update required' "$log_file"; then + cento_warn "Installed Discord host is out of date. Run: cento discord update" + fi + cento_warn "Log: $log_file" + [[ -f "$log_file" ]] && tail -40 "$log_file" >&2 + return 1 +} + +launch_discord() { + local log_file + log_file=$(latest_log_path) + cento_info "Starting Discord..." + + if ! launch_user_local_discord "$log_file"; then + launch_system_discord "$log_file" || cento_die "Could not find a Discord launcher. Run: cento discord update" + fi + + wait_for_discord "$log_file" +} + +print_status() { + local executable + if discord_app_running; then + printf 'running: yes\n' + discord_app_pids | sed 's/^/pid: /' + elif discord_running; then + printf 'running: bootstrap-only\n' + discord_pids | sed 's/^/pid: /' else - cento_die "Could not find a Discord launcher: discord, Discord, flatpak com.discordapp.Discord, or snap discord" + printf 'running: no\n' fi - cento_info "Discord restart requested." + if executable=$(user_local_executable); then + printf 'user_local: %s\n' "$executable" + else + printf 'user_local: missing (%s)\n' "$DISCORD_INSTALL_DIR" + fi + + if cento_have_cmd discord; then + printf 'system_launcher: %s\n' "$(command -v discord)" + else + printf 'system_launcher: missing\n' + fi +} + +user_local_executable() { + local executable + if executable=$(user_local_host); then + printf '%s\n' "$executable" + elif executable=$(user_local_wrapper); then + printf '%s\n' "$executable" + else + return 1 + fi +} + +user_local_wrapper() { + if [[ -x "$DISCORD_INSTALL_DIR/discord" ]]; then + printf '%s\n' "$DISCORD_INSTALL_DIR/discord" + elif [[ -x "$DISCORD_INSTALL_DIR/Discord" ]]; then + printf '%s\n' "$DISCORD_INSTALL_DIR/Discord" + else + return 1 + fi +} + +user_local_host() { + local host + if [[ -x "$DISCORD_CONFIG_DIR/Discord" ]]; then + printf '%s\n' "$DISCORD_CONFIG_DIR/Discord" + return 0 + fi + host=$(find "$DISCORD_CONFIG_DIR" -maxdepth 2 -type f -name Discord -perm -111 2>/dev/null | sort -V | tail -1) + if [[ -n "$host" ]]; then + printf '%s\n' "$host" + return 0 + fi + return 1 +} + +prepare_user_local_discord() { + local log_file=$1 + local executable + if executable=$(user_local_host); then + printf '%s\n' "$executable" + return 0 + fi + user_local_wrapper >/dev/null || return 1 + if [[ -x "$DISCORD_INSTALL_DIR/updater_bootstrap" ]]; then + cento_info "Bootstrapping Discord host without zenity..." + cento_ensure_dir "$DISCORD_CONFIG_DIR" + if "$DISCORD_INSTALL_DIR/updater_bootstrap" --no-zenity "$DISCORD_CONFIG_DIR" stable "$DISCORD_UPDATES_URL" >>"$log_file" 2>&1; then + if executable=$(user_local_host); then + printf '%s\n' "$executable" + return 0 + fi + fi + cento_warn "Discord bootstrap did not produce a host executable. Log: $log_file" + fi + return 1 +} +installed_version() { + local dir=$1 + python3 - "$dir" <<'PY' +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +for rel in ("resources/build_info.json", "resources/app/package.json"): + path = root / rel + if not path.exists(): + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + continue + value = data.get("version") or data.get("buildNumber") + if value: + print(value) + raise SystemExit(0) +raise SystemExit(0) +PY +} + +update_discord() { + require_linux + cento_require_cmd curl + cento_require_cmd tar + cento_require_cmd python3 + + local parent tmp archive extracted backup version + parent=$(dirname -- "$DISCORD_INSTALL_DIR") + cento_ensure_dir "$parent" + tmp=$(mktemp -d "$parent/discord-update.XXXXXX") + trap 'if [[ -n "${tmp:-}" && -d "$tmp" ]]; then rm -rf "$tmp"; fi' RETURN + archive="$tmp/discord.tar.gz" + + cento_info "Downloading latest Discord Linux tarball..." + curl -fL --retry 2 --connect-timeout 20 -o "$archive" "$DISCORD_DOWNLOAD_URL" + + cento_info "Extracting Discord..." + tar -xzf "$archive" -C "$tmp" + extracted="$tmp/Discord" + if [[ ! -x "$extracted/Discord" && ! -x "$extracted/discord" ]]; then + cento_die "Downloaded archive did not contain executable Discord/Discord or Discord/discord" + fi + + version=$(installed_version "$extracted") + backup="" + if [[ -e "$DISCORD_INSTALL_DIR" ]]; then + backup="$parent/Discord.previous.$(cento_timestamp)" + mv "$DISCORD_INSTALL_DIR" "$backup" + fi + mv "$extracted" "$DISCORD_INSTALL_DIR" + chmod +x "$DISCORD_INSTALL_DIR/Discord" "$DISCORD_INSTALL_DIR/discord" 2>/dev/null || true + rm -rf "$tmp" + tmp="" + trap - RETURN + + cento_info "Installed Discord ${version:-latest} to $DISCORD_INSTALL_DIR" + if [[ -n "$backup" ]]; then + cento_info "Previous user-local Discord moved to $backup" + fi +} + +rerun_discord() { + require_linux + stop_discord + launch_discord } -stop_discord -launch_discord +case "$ACTION" in + rerun) + [[ $# -eq 0 ]] || cento_die "Usage: restart_discord.sh rerun" + rerun_discord + ;; + update) + rerun_after_update=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --rerun) + rerun_after_update=1 + shift + ;; + *) + cento_die "Usage: restart_discord.sh update [--rerun]" + ;; + esac + done + update_discord + if [[ "$rerun_after_update" -eq 1 ]]; then + rerun_discord + fi + ;; + status) + [[ $# -eq 0 ]] || cento_die "Usage: restart_discord.sh status" + print_status + ;; + *) + cento_die "Unknown action: $ACTION" + ;; +esac diff --git a/scripts/spend_ledger.py b/scripts/spend_ledger.py new file mode 100644 index 0000000..d58efa5 --- /dev/null +++ b/scripts/spend_ledger.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""Append-only spend ledger helpers for Cento API and run accounting.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +SCHEMA_VERSION = "cento.spend_ledger.entry.v1" + +PRICE_BOOK: dict[str, dict[str, float]] = { + "gpt-5.4-pro": { + "input_token": 30.0 / 1_000_000, + "output_token": 180.0 / 1_000_000, + }, + "gpt-image-1": { + "input_text_token": 5.0 / 1_000_000, + "input_image_token": 10.0 / 1_000_000, + "output_image_token": 40.0 / 1_000_000, + }, +} + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def read_jsonl(path: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return rows + for line in text.splitlines(): + if not line.strip(): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + rows.append(payload) + return rows + + +def write_jsonl(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") + + +def stable_record_id(payload: dict[str, Any]) -> str: + base = json.dumps( + { + "run_id": payload.get("run_id"), + "lane": payload.get("lane"), + "category": payload.get("category"), + "model": payload.get("model"), + "response_id": payload.get("response_id"), + "status": payload.get("status"), + "written_at": payload.get("written_at"), + }, + sort_keys=True, + ) + return "spend-" + hashlib.sha256(base.encode("utf-8")).hexdigest()[:16] + + +def response_id_from_payload(payload: dict[str, Any]) -> str: + for key in ("id", "response_id"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + response = payload.get("response") + if isinstance(response, dict): + return response_id_from_payload(response) + return "" + + +def _number(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def token_usage(usage: dict[str, Any]) -> dict[str, int]: + input_details = usage.get("input_tokens_details") if isinstance(usage.get("input_tokens_details"), dict) else {} + output_details = usage.get("output_tokens_details") if isinstance(usage.get("output_tokens_details"), dict) else {} + image_details = usage.get("image_tokens_details") if isinstance(usage.get("image_tokens_details"), dict) else {} + return { + "input_tokens": int(_number(usage.get("input_tokens"))), + "output_tokens": int(_number(usage.get("output_tokens"))), + "total_tokens": int(_number(usage.get("total_tokens"))), + "input_text_tokens": int(_number(usage.get("input_text_tokens") or input_details.get("text_tokens") or usage.get("text_tokens"))), + "input_image_tokens": int(_number(usage.get("input_image_tokens") or input_details.get("image_tokens") or image_details.get("input_tokens"))), + "output_image_tokens": int(_number(usage.get("output_image_tokens") or output_details.get("image_tokens") or image_details.get("output_tokens"))), + } + + +def estimate_cost_usd(model: str, category: str, usage: dict[str, Any]) -> tuple[float, str, dict[str, int]]: + tokens = token_usage(usage) + pricing = PRICE_BOOK.get(model) + if not pricing: + return 0.0, "unknown-pricing", tokens + if not usage: + return 0.0, "no-usage", tokens + + if category == "image": + text_tokens = tokens["input_text_tokens"] or tokens["input_tokens"] + image_input_tokens = tokens["input_image_tokens"] + output_image_tokens = tokens["output_image_tokens"] or tokens["output_tokens"] + cost = ( + text_tokens * pricing.get("input_text_token", 0.0) + + image_input_tokens * pricing.get("input_image_token", 0.0) + + output_image_tokens * pricing.get("output_image_token", 0.0) + ) + return round(cost, 8), "estimated", tokens + + input_tokens = tokens["input_tokens"] + output_tokens = tokens["output_tokens"] + if input_tokens == 0 and output_tokens == 0 and tokens["total_tokens"]: + return 0.0, "unknown-token-split", tokens + cost = input_tokens * pricing.get("input_token", 0.0) + output_tokens * pricing.get("output_token", 0.0) + return round(cost, 8), "estimated", tokens + + +def build_api_record( + *, + run_id: str, + lane: str, + category: str, + model: str, + status: str, + usage: dict[str, Any] | None = None, + response_id: str = "", + response: dict[str, Any] | None = None, + artifact: str = "", + note: str = "", + cost_usd: float | None = None, + cost_accuracy: str = "", +) -> dict[str, Any]: + usage_payload = usage if isinstance(usage, dict) else {} + if not response_id and isinstance(response, dict): + response_id = response_id_from_payload(response) + estimate, estimated_accuracy, tokens = estimate_cost_usd(model, category, usage_payload) + if cost_usd is None: + cost_usd = estimate + if not cost_accuracy: + cost_accuracy = estimated_accuracy + dedupe_key = f"openai:{response_id}" if response_id else "" + record = { + "schema_version": SCHEMA_VERSION, + "written_at": now_iso(), + "run_id": run_id, + "lane": lane, + "category": category, + "provider": "openai", + "model": model, + "status": status, + "response_id": response_id, + "dedupe_key": dedupe_key, + "usage": usage_payload, + "normalized_tokens": tokens, + "pricing": PRICE_BOOK.get(model, {}), + "cost_usd": round(float(cost_usd), 8), + "cost_accuracy": cost_accuracy, + "billable": status not in {"skipped", "started"}, + "artifact": artifact, + "note": note, + } + record["record_id"] = stable_record_id(record) + return record + + +def build_factory_record(*, run_id: str, status: str, cost_usd: float = 0.0, note: str = "", artifact: str = "") -> dict[str, Any]: + record = { + "schema_version": SCHEMA_VERSION, + "written_at": now_iso(), + "run_id": run_id, + "lane": "factory", + "category": "factory", + "provider": "local", + "model": "", + "status": status, + "response_id": "", + "dedupe_key": "", + "usage": {}, + "normalized_tokens": {}, + "pricing": {}, + "cost_usd": round(float(cost_usd), 8), + "cost_accuracy": "exact-zero" if cost_usd == 0 else "operator-supplied", + "billable": bool(cost_usd), + "artifact": artifact, + "note": note, + } + record["record_id"] = stable_record_id(record) + return record + + +def build_dashboard_delta_record(*, run_id: str, delta_usd: float, note: str = "") -> dict[str, Any]: + record = { + "schema_version": SCHEMA_VERSION, + "written_at": now_iso(), + "run_id": run_id, + "lane": "dashboard", + "category": "dashboard_delta", + "provider": "openai", + "model": "", + "status": "unattributed", + "response_id": "", + "dedupe_key": "", + "usage": {}, + "normalized_tokens": {}, + "pricing": {}, + "cost_usd": round(float(delta_usd), 8), + "cost_accuracy": "dashboard-delta", + "billable": True, + "artifact": "", + "note": note, + } + record["record_id"] = stable_record_id(record) + return record + + +def build_dashboard_total_record(*, run_id: str, total_usd: float, note: str = "") -> dict[str, Any]: + record = { + "schema_version": SCHEMA_VERSION, + "written_at": now_iso(), + "run_id": run_id, + "lane": "dashboard", + "category": "dashboard_total_baseline", + "provider": "openai", + "model": "", + "status": "baseline", + "response_id": "", + "dedupe_key": f"dashboard-total-baseline:{run_id}", + "usage": {}, + "normalized_tokens": {}, + "pricing": {}, + "cost_usd": round(float(total_usd), 8), + "cost_accuracy": "dashboard-total-snapshot", + "billable": True, + "artifact": "", + "note": note, + } + record["record_id"] = stable_record_id(record) + return record + + +def append_record(path: Path, record: dict[str, Any], *, dedupe: bool = True) -> dict[str, Any]: + payload = dict(record) + dedupe_key = str(payload.get("dedupe_key") or "") + if dedupe and dedupe_key: + for existing in read_jsonl(path): + if str(existing.get("dedupe_key") or "") == dedupe_key and not existing.get("duplicate_of"): + payload["duplicate_of"] = existing.get("record_id") or dedupe_key + payload["billable"] = False + payload["cost_usd"] = 0.0 + payload["cost_accuracy"] = "duplicate-response-id" + payload["record_id"] = stable_record_id(payload) + break + write_jsonl(path, payload) + return payload + + +def append_records(paths: list[Path], record: dict[str, Any]) -> list[dict[str, Any]]: + return [append_record(path, record) for path in paths] + + +def summarize_records(records: list[dict[str, Any]]) -> dict[str, Any]: + seen: set[str] = set() + counted: list[dict[str, Any]] = [] + duplicates: list[dict[str, Any]] = [] + for record in records: + dedupe_key = str(record.get("dedupe_key") or "") + if dedupe_key and dedupe_key in seen: + duplicates.append(record) + continue + if dedupe_key: + seen.add(dedupe_key) + if record.get("duplicate_of"): + duplicates.append(record) + continue + counted.append(record) + + by_category: dict[str, float] = {} + unknown: list[dict[str, Any]] = [] + for record in counted: + category = str(record.get("category") or "unknown") + cost = float(record.get("cost_usd") or 0.0) + by_category[category] = round(by_category.get(category, 0.0) + cost, 8) + if str(record.get("cost_accuracy") or "").startswith("unknown"): + unknown.append(record) + + api_categories = {"pro", "image", "api"} + return { + "schema_version": "cento.spend_ledger.summary.v1", + "generated_at": now_iso(), + "record_count": len(records), + "counted_record_count": len(counted), + "duplicate_count": len(duplicates), + "response_id_count": len(seen), + "total_cost_usd": round(sum(float(item.get("cost_usd") or 0.0) for item in counted), 8), + "factory_cost_usd": round(by_category.get("factory", 0.0), 8), + "api_cost_usd": round(sum(by_category.get(category, 0.0) for category in api_categories), 8), + "pro_cost_usd": round(by_category.get("pro", 0.0), 8), + "image_cost_usd": round(by_category.get("image", 0.0), 8), + "dashboard_total_baseline_usd": round(by_category.get("dashboard_total_baseline", 0.0), 8), + "unattributed_dashboard_delta_usd": round(by_category.get("dashboard_delta", 0.0), 8), + "by_category": by_category, + "unknown_record_count": len(unknown), + } + + +def summarize_paths(paths: list[Path]) -> dict[str, Any]: + records: list[dict[str, Any]] = [] + for path in paths: + records.extend(read_jsonl(path)) + return summarize_records(records) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Summarize Cento spend ledger JSONL files.") + parser.add_argument("ledger", nargs="+", help="Ledger JSONL path(s).") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + summary = summarize_paths([Path(item) for item in args.ledger]) + if args.json: + print(json.dumps(summary, indent=2, sort_keys=True)) + else: + print(f"total_cost_usd: {summary['total_cost_usd']:.8f}") + print(f"factory_cost_usd: {summary['factory_cost_usd']:.8f}") + print(f"api_cost_usd: {summary['api_cost_usd']:.8f}") + print(f"unattributed_dashboard_delta_usd: {summary['unattributed_dashboard_delta_usd']:.8f}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tool_foundry.py b/scripts/tool_foundry.py new file mode 100644 index 0000000..1a57e7d --- /dev/null +++ b/scripts/tool_foundry.py @@ -0,0 +1,1337 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +RUN_ROOT = ROOT / "workspace" / "runs" / "foundry" +SCHEMA_SPEC = "cento.foundry.spec.v1" +SCHEMA_PLAN = "cento.foundry.plan_receipt.v1" +SCHEMA_EXECUTION = "cento.foundry.execution_receipt.v1" +SCHEMA_VALIDATION = "cento.foundry.validation.v1" +SCHEMA_COST = "cento.foundry.cost_receipt.v1" +SCHEMA_STORAGE_POLICY = "cento.foundry.storage_policy.v1" +SCHEMA_DEMO = "cento.foundry.demo_evidence.v1" +SCHEMA_REAL_FILE_MANIFEST = "cento.foundry.real_file_manifest.v1" +SCHEMA_MATERIALIZATION_PLAN = "cento.foundry.materialization_plan.v1" +SCHEMA_MATERIALIZATION_RECEIPT = "cento.foundry.materialization_receipt.v1" +DEFAULT_FIXTURE = "client-intake-hub" +DEFAULT_DOMAIN = "career-consulting" +DEFAULT_BUDGET_USD = 10.0 +DEFAULT_MAX_BUDGET_USD = 20.0 +DEFAULT_REAL_FILE_TARGET_ROOT = "templates/foundry/client-intake-hub" +DEFAULT_CLIENT_INTAKE_DOCS_PATH = "docs/client-intake-hub.md" + + +@dataclass(frozen=True) +class ExternalResult: + command: list[str] + returncode: int + stdout: str + stderr: str + payload: Any + + def receipt(self) -> dict[str, Any]: + return { + "command": self.command, + "returncode": self.returncode, + "stdout": self.stdout, + "stderr": self.stderr, + "payload": self.payload, + } + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def now_stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") + return slug[:80] or "tool" + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def repo_path(value: str | Path) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return ROOT / path + + +def run_arg(path: Path) -> str: + return rel(path) if path.resolve().is_relative_to(ROOT.resolve()) else path.as_posix() + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + if isinstance(payload, dict): + return payload + return {} + + +def parse_json(text: str) -> Any: + stripped = text.strip() + if not stripped: + return None + try: + return json.loads(stripped) + except json.JSONDecodeError: + return None + + +def write_json(path: Path, payload: Any) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return path + + +def write_text(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text.rstrip() + "\n", encoding="utf-8") + return path + + +def sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def normalize_repo_relative(value: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError("path must not be empty") + path = Path(text) + if path.is_absolute(): + raise ValueError(f"path must be repo-relative: {value}") + parts = path.parts + if any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"path must not contain traversal segments: {value}") + normalized = Path(*parts).as_posix() + if normalized in {"", "."}: + raise ValueError(f"path must be repo-relative: {value}") + return normalized + + +def path_inside(child: str, parent: str) -> bool: + parent = parent.rstrip("/") + return child == parent or child.startswith(parent + "/") + + +def normalize_real_file_target_root(value: str = DEFAULT_REAL_FILE_TARGET_ROOT) -> str: + target_root = normalize_repo_relative(value or DEFAULT_REAL_FILE_TARGET_ROOT) + if not target_root.startswith("templates/foundry/"): + raise ValueError("Foundry real-file target root must be under templates/foundry/") + return target_root + + +def validate_real_file_target_path(path: str, target_root: str) -> str: + normalized = normalize_repo_relative(path) + if path_inside(normalized, target_root) or normalized == DEFAULT_CLIENT_INTAKE_DOCS_PATH: + return normalized + raise ValueError(f"Foundry materialization path is outside the allowlist: {path}") + + +def append_event(run_dir: Path, event: str, payload: dict[str, Any] | None = None) -> None: + run_dir.mkdir(parents=True, exist_ok=True) + row = {"ts": now_iso(), "event": event, **(payload or {})} + with (run_dir / "events.ndjson").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, sort_keys=True) + "\n") + + +def run_external(command: list[str]) -> ExternalResult: + completed = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + return ExternalResult( + command=command, + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + payload=parse_json(completed.stdout), + ) + + +def output(payload: dict[str, Any], *, json_flag: bool) -> None: + if json_flag: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"{payload.get('status', 'unknown')} {payload.get('run_dir', '')}".strip()) + + +def resolve_run_dir(run_id_or_path: str, *, create: bool = False) -> Path: + if not run_id_or_path: + raise SystemExit("run id is required") + raw = Path(run_id_or_path) + if raw.is_absolute() or "/" in run_id_or_path: + run_dir = repo_path(raw) + else: + run_dir = RUN_ROOT / run_id_or_path + if create: + run_dir.mkdir(parents=True, exist_ok=True) + return run_dir + + +def default_run_id(idea: str, domain: str) -> str: + return f"foundry-{slugify(domain)}-{slugify(idea)}-{now_stamp()}" + + +def load_spec(run_dir: Path) -> dict[str, Any]: + spec = read_json(run_dir / "foundry-spec.json") + if spec.get("schema_version") != SCHEMA_SPEC: + raise SystemExit(f"missing Foundry spec: {run_dir / 'foundry-spec.json'}") + return spec + + +def title_from_idea(idea: str) -> str: + cleaned = " ".join(idea.strip().split()) + return cleaned[:1].upper() + cleaned[1:] if cleaned else "Client Intake Hub" + + +def write_cost_receipt( + run_dir: Path, + *, + mode: str, + runtime: str, + budget_usd: float | None, + max_budget_usd: float | None, + actual_cost_usd: float = 0.0, + ai_calls_used: int = 0, +) -> dict[str, Any]: + hard_cap = float(max_budget_usd if max_budget_usd is not None else DEFAULT_MAX_BUDGET_USD) + target = float(budget_usd if budget_usd is not None else DEFAULT_BUDGET_USD) + receipt = { + "schema_version": SCHEMA_COST, + "run_id": run_dir.name, + "mode": mode, + "runtime": runtime, + "budget_usd": target, + "max_budget_usd": hard_cap, + "actual_cost_usd": float(actual_cost_usd), + "ai_calls_used": int(ai_calls_used), + "hard_cap_exceeded": float(actual_cost_usd) > hard_cap, + "live_requires_explicit_budget": True, + "written_at": now_iso(), + } + write_json(run_dir / "cost_receipt.json", receipt) + return receipt + + +def write_storage_policy(run_dir: Path, spec: dict[str, Any]) -> dict[str, Any]: + policy = { + "schema_version": SCHEMA_STORAGE_POLICY, + "run_id": run_dir.name, + "domain": spec.get("domain", DEFAULT_DOMAIN), + "tool": spec.get("tool", {}).get("id", DEFAULT_FIXTURE), + "default_location": "local", + "oci_storage_tier": "Standard", + "public_access": "blocked", + "client_data": { + "contains_real_client_data": False, + "fixture_only": True, + "requires_operator_confirmation_for_real_files": True, + "never_upload_without_explicit_live_flag": True, + }, + "allowed_cloud_artifacts": ["dummy receipts", "fixture screenshots", "non-sensitive generated evidence"], + "blocked_cloud_artifacts": ["resumes", "LinkedIn exports", "client notes", "secrets", "tokens", "raw PII"], + "written_at": now_iso(), + } + write_json(run_dir / "storage-policy.json", policy) + return policy + + +def seed_client_intake_hub(run_dir: Path, spec: dict[str, Any]) -> dict[str, str]: + tool_dir = run_dir / "tool" / "client-intake-hub" + files = { + "schema": tool_dir / "client-profile.schema.json", + "commands": tool_dir / "command-api.json", + "ui": tool_dir / "client-intake-hub.html", + "docs": tool_dir / "operator-docs.md", + "storage": tool_dir / "storage-leak-policy.json", + "validation": tool_dir / "validation-plan.json", + } + write_json( + files["schema"], + { + "schema_version": "cento.client_intake_hub.profile.v1", + "fields": { + "client_id": "string", + "name": "string", + "target_role": "string", + "source_materials": ["resume", "linkedin", "job-description", "notes"], + "deliverables": ["intake-synthesis", "resume-review", "linkedin-review", "action-plan"], + }, + "fixture_profile": { + "client_id": "fixture-client-001", + "name": "Ada Lovelace", + "target_role": "Principal platform engineer", + "contains_real_client_data": False, + }, + }, + ) + write_json( + files["commands"], + { + "schema_version": "cento.client_intake_hub.commands.v1", + "commands": [ + "cento crm intake init --person \"Ada Lovelace\"", + "cento crm intake add --person \"Ada Lovelace\" --kind resume --file ./resume.pdf", + "cento crm intake plan --person \"Ada Lovelace\"", + "cento foundry status " + run_dir.name, + ], + "source_tool": "cento crm", + }, + ) + write_text( + files["ui"], + """ + + + + + + Client Intake Hub + + + +
    +
    +
    +

    Client Intake Hub

    +

    Career consulting fixture generated by Cento Tool Foundry.

    +
    +
    Fixture Ready
    +
    +
    +

    Profile

    +
    +
    Ada Lovelace
    Fixture client
    +
    Principal platform engineer
    Target role
    +
    No real client data
    Privacy state
    +
    +
    +
    +

    Deliverables

    +
    +
    Intake synthesis
    +
    Resume impact review
    +
    LinkedIn positioning review
    +
    +
    +
    + + +""", + ) + write_text( + files["docs"], + f""" +# Client Intake Hub Operator Notes + +This fixture proves the Foundry pipeline for `{spec.get('domain', DEFAULT_DOMAIN)}` without using real client data. + +The generated hub should become the private workspace for profile intake, source materials, deliverables, storage policy, and validation evidence. +""", + ) + write_json( + files["storage"], + { + "schema_version": "cento.client_intake_hub.storage.v1", + "local_first": True, + "oci_allowed": False, + "public_access_allowed": False, + "fixture_only": True, + "blocked_real_inputs": ["resume", "linkedin export", "client notes", "private job search notes"], + }, + ) + write_json( + files["validation"], + { + "schema_version": "cento.client_intake_hub.validation.v1", + "checks": [ + {"name": "profile schema exists", "type": "file_exists", "path": run_arg(files["schema"])}, + {"name": "ui preview exists", "type": "file_exists", "path": run_arg(files["ui"])}, + {"name": "storage policy blocks public access", "type": "json_value", "path": run_arg(files["storage"])}, + ], + }, + ) + return {key: run_arg(path) for key, path in files.items()} + + +def client_intake_docs_page(run_dir: Path, spec: dict[str, Any], target_root: str) -> str: + return f"""# Client Intake Hub + +Client Intake Hub is the first real-file Tool Foundry bundle for the career consulting workflow. It is still fixture-only: it proves Cento can materialize a repo-ready tool surface without using real resumes, LinkedIn exports, private notes, or client PII. + +## Current State + +- `status`: materialized MVP +- `target_root`: `{target_root}` +- `domain`: `{spec.get('domain', DEFAULT_DOMAIN)}` +- `privacy`: fixture data only, local-first, no public upload + +## Materialized Files + +- `{target_root}/client-intake-hub.html` +- `{target_root}/client-profile.schema.json` +- `{target_root}/command-api.json` +- `{target_root}/storage-leak-policy.json` +- `{target_root}/validation-plan.json` +- `{target_root}/README.md` + +## Preview + +Run the CRM server and open the Studio view: + +```bash +cento crm serve +``` + +The CRM exposes Foundry tool metadata at `/api/foundry/tools` and serves the generated preview from `/foundry/client-intake-hub/client-intake-hub.html`. + +## Safety + +- The bundle uses only the built-in Ada Lovelace fixture profile. +- Existing materialized files are not overwritten by Foundry unless their content is identical. +- OCI upload is not part of this MVP; storage remains local unless a later explicit storage promotion is approved. +""" + + +def stable_json(payload: dict[str, Any]) -> str: + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def materialized_command_api_content() -> str: + return stable_json( + { + "schema_version": "cento.client_intake_hub.commands.v1", + "commands": [ + "cento crm intake init --person \"Ada Lovelace\"", + "cento crm intake add --person \"Ada Lovelace\" --kind resume --file ./resume.pdf", + "cento crm intake plan --person \"Ada Lovelace\"", + "cento foundry status RUN_ID", + "cento foundry materialize RUN_ID --target-root templates/foundry/client-intake-hub --dry-run --json", + ], + "source_tool": "cento crm", + } + ) + + +def materialized_validation_plan_content(target_root: str) -> str: + return stable_json( + { + "schema_version": "cento.client_intake_hub.validation.v1", + "checks": [ + {"name": "profile schema exists", "type": "file_exists", "path": f"{target_root}/client-profile.schema.json"}, + {"name": "ui preview exists", "type": "file_exists", "path": f"{target_root}/client-intake-hub.html"}, + {"name": "storage policy blocks public access", "type": "json_value", "path": f"{target_root}/storage-leak-policy.json"}, + {"name": "CRM Foundry tools API returns metadata", "type": "http_get", "path": "/api/foundry/tools"}, + ], + } + ) + + +def real_file_entries(run_dir: Path, spec: dict[str, Any], target_root: str) -> list[dict[str, str]]: + seeded = seed_client_intake_hub(run_dir, spec) + mapping = [ + ("schema", f"{target_root}/client-profile.schema.json", "client profile schema"), + ("commands", f"{target_root}/command-api.json", "command api map"), + ("ui", f"{target_root}/client-intake-hub.html", "no-build preview"), + ("storage", f"{target_root}/storage-leak-policy.json", "storage and leak policy"), + ("validation", f"{target_root}/validation-plan.json", "validation plan"), + ("docs", f"{target_root}/README.md", "operator notes"), + ] + entries: list[dict[str, str]] = [] + for source_key, target_path, description in mapping: + source_path = repo_path(seeded[source_key]) + if source_key == "commands": + content = materialized_command_api_content() + elif source_key == "validation": + content = materialized_validation_plan_content(target_root) + else: + content = source_path.read_text(encoding="utf-8") + entries.append( + { + "id": source_key, + "description": description, + "source_path": rel(source_path), + "target_path": validate_real_file_target_path(target_path, target_root), + "content": content.rstrip() + "\n", + "privacy_class": "fixture-public-safe", + } + ) + docs_content = client_intake_docs_page(run_dir, spec, target_root) + entries.append( + { + "id": "human-docs", + "description": "human-facing Client Intake Hub docs page", + "source_path": rel(run_dir / "foundry-spec.json"), + "target_path": validate_real_file_target_path(DEFAULT_CLIENT_INTAKE_DOCS_PATH, target_root), + "content": docs_content.rstrip() + "\n", + "privacy_class": "fixture-public-safe", + } + ) + return entries + + +def write_real_file_manifest(run_dir: Path, spec: dict[str, Any], target_root: str, entries: list[dict[str, str]]) -> dict[str, Any]: + manifest = { + "schema_version": SCHEMA_REAL_FILE_MANIFEST, + "run_id": run_dir.name, + "fixture": spec.get("tool", {}).get("id", DEFAULT_FIXTURE), + "target_root": target_root, + "docs_path": DEFAULT_CLIENT_INTAKE_DOCS_PATH, + "privacy": { + "contains_real_client_data": False, + "fixture_only": True, + "cloud_upload_allowed": False, + }, + "files": [ + { + "id": item["id"], + "description": item["description"], + "source_path": item["source_path"], + "target_path": item["target_path"], + "content_sha256": sha256_text(item["content"]), + "privacy_class": item["privacy_class"], + } + for item in entries + ], + "written_at": now_iso(), + } + write_json(run_dir / "real_file_manifest.json", manifest) + return manifest + + +def build_materialization_plan(run_dir: Path, target_root: str, entries: list[dict[str, str]]) -> dict[str, Any]: + rows: list[dict[str, Any]] = [] + blocked = False + for item in entries: + target_path = validate_real_file_target_path(item["target_path"], target_root) + target = ROOT / target_path + content_hash = sha256_text(item["content"]) + existing_hash = "" + if target.exists(): + existing = target.read_text(encoding="utf-8") + existing_hash = sha256_text(existing) + action = "skip_identical" if existing_hash == content_hash else "block_existing_changed" + else: + action = "write" + if action == "block_existing_changed": + blocked = True + rows.append( + { + "id": item["id"], + "target_path": target_path, + "source_path": item["source_path"], + "action": action, + "exists": target.exists(), + "content_sha256": content_hash, + "existing_sha256": existing_hash, + "privacy_class": item["privacy_class"], + } + ) + plan = { + "schema_version": SCHEMA_MATERIALIZATION_PLAN, + "run_id": run_dir.name, + "target_root": target_root, + "status": "blocked" if blocked else "ready", + "files": rows, + "blocked_reason": "existing changed target files would be overwritten" if blocked else "", + "written_at": now_iso(), + } + write_json(run_dir / "materialization_plan.json", plan) + return plan + + +def materialize_real_files(run_dir: Path, spec: dict[str, Any], *, target_root: str, apply: bool) -> dict[str, Any]: + entries = real_file_entries(run_dir, spec, target_root) + manifest = write_real_file_manifest(run_dir, spec, target_root, entries) + plan = build_materialization_plan(run_dir, target_root, entries) + written: list[dict[str, Any]] = [] + status = "blocked" if plan["status"] == "blocked" else ("materialized" if apply else "planned") + if apply and plan["status"] == "ready": + content_by_path = {item["target_path"]: item["content"] for item in entries} + for row in plan["files"]: + target_path = row["target_path"] + if row["action"] == "skip_identical": + written.append({**row, "status": "skipped_identical"}) + continue + target = ROOT / target_path + write_text(target, content_by_path[target_path]) + written.append({**row, "status": "written"}) + receipt = { + "schema_version": SCHEMA_MATERIALIZATION_RECEIPT, + "run_id": run_dir.name, + "status": status, + "mode": "apply" if apply else "dry-run", + "target_root": target_root, + "real_file_manifest": rel(run_dir / "real_file_manifest.json"), + "materialization_plan": rel(run_dir / "materialization_plan.json"), + "files": written if apply and plan["status"] == "ready" else plan["files"], + "blocked_reason": plan.get("blocked_reason", ""), + "applied": bool(apply and plan["status"] == "ready"), + "file_count": len(manifest["files"]), + "written_at": now_iso(), + } + write_json(run_dir / "materialization_receipt.json", receipt) + return receipt + + +def write_demo_evidence(run_dir: Path, spec: dict[str, Any], seeded: dict[str, str]) -> dict[str, Any]: + demo = { + "schema_version": SCHEMA_DEMO, + "run_id": run_dir.name, + "tool": spec.get("tool", {}).get("id", DEFAULT_FIXTURE), + "mode": "fixture", + "preview": seeded.get("ui", ""), + "claim": "Client Intake Hub fixture artifacts exist and are ready for deterministic Foundry validation.", + "evidence": [ + seeded.get("schema", ""), + seeded.get("commands", ""), + seeded.get("ui", ""), + seeded.get("docs", ""), + seeded.get("storage", ""), + seeded.get("validation", ""), + ], + "written_at": now_iso(), + } + write_json(run_dir / "demo-evidence.json", demo) + write_text( + run_dir / "demo-evidence.md", + "\n".join( + [ + "# Foundry Demo Evidence", + "", + f"- Run: `{run_dir.name}`", + f"- Tool: `{demo['tool']}`", + f"- Preview: `{demo['preview']}`", + "- Mode: `fixture`", + "", + ] + ), + ) + return demo + + +def client_intake_fixture_targets() -> dict[str, str]: + return { + "schema": "docs/career-intake.md", + "commands": "docs/crm-module.md", + "ui": "standards/tui.md", + "docs": "standards/tool-registration.md", + "storage": "standards/mcp.md", + "validation": "docs/validator-tier0.md", + } + + +def write_summary(run_dir: Path, status: str, detail: str) -> None: + write_text( + run_dir / "summary.md", + "\n".join( + [ + "# Cento Tool Foundry Run", + "", + f"- Run: `{run_dir.name}`", + f"- Status: `{status}`", + f"- Detail: {detail}", + f"- Updated: `{now_iso()}`", + "", + "## Core Artifacts", + "", + "- `foundry-spec.json`", + "- `factory_handoff.json`", + "- `workset.json`", + "- `execution_receipt.json`", + "- `cost_receipt.json`", + "- `storage-policy.json`", + "- `demo-evidence.json`", + "- `validation_summary.json`", + "- `real_file_manifest.json` (real-file mode)", + "- `materialization_plan.json` (real-file mode)", + "- `materialization_receipt.json` (real-file mode)", + ] + ), + ) + + +def build_spec(args: argparse.Namespace, run_dir: Path) -> dict[str, Any]: + idea = str(args.idea or "client intake hub") + fixture = str(getattr(args, "fixture", DEFAULT_FIXTURE) or DEFAULT_FIXTURE) + domain = str(args.domain or DEFAULT_DOMAIN) + return { + "schema_version": SCHEMA_SPEC, + "run_id": run_dir.name, + "created_at": now_iso(), + "idea": idea, + "domain": domain, + "mode": "tool_foundry", + "max_parallel": int(args.max_parallel or 6), + "budget": { + "target_usd": float(args.budget_usd if args.budget_usd is not None else DEFAULT_BUDGET_USD), + "hard_max_usd": float(args.max_budget_usd if args.max_budget_usd is not None else DEFAULT_MAX_BUDGET_USD), + "live_requires_explicit_budget": True, + }, + "tool": { + "id": fixture, + "title": "Client Intake Hub" if fixture == DEFAULT_FIXTURE else title_from_idea(idea), + "domain": domain, + "audience": "career consulting operator", + "privacy": "fixture-only until real client data is explicitly supplied", + }, + "pipeline": { + "factory": "cento factory", + "workset": "cento workset", + "train": "cento parallel-delivery train", + "storage": "cento object-storage / cento storage", + "demo": "cento demo-evidence compatible manifest", + }, + } + + +def command_create(args: argparse.Namespace) -> int: + run_id = args.run_id or default_run_id(args.idea, args.domain) + run_dir = repo_path(args.out) if args.out else resolve_run_dir(run_id, create=True) + run_dir.mkdir(parents=True, exist_ok=True) + spec = build_spec(args, run_dir) + seeded = seed_client_intake_hub(run_dir, spec) + write_json(run_dir / "foundry-spec.json", spec) + write_storage_policy(run_dir, spec) + write_cost_receipt(run_dir, mode="created", runtime="none", budget_usd=args.budget_usd, max_budget_usd=args.max_budget_usd) + write_demo_evidence(run_dir, spec, seeded) + write_summary(run_dir, "created", "Foundry spec and Client Intake Hub fixture seed artifacts are ready.") + append_event(run_dir, "foundry_created", {"tool": spec["tool"]["id"], "domain": spec["domain"]}) + payload = {"status": "created", "run_id": run_dir.name, "run_dir": rel(run_dir), "foundry_spec": rel(run_dir / "foundry-spec.json")} + if not getattr(args, "quiet", False): + output(payload, json_flag=args.json) + return 0 + + +def ensure_created(run_dir: Path) -> dict[str, Any]: + spec = load_spec(run_dir) + seeded = seed_client_intake_hub(run_dir, spec) + write_storage_policy(run_dir, spec) + if not (run_dir / "demo-evidence.json").exists(): + write_demo_evidence(run_dir, spec, seeded) + return spec + + +def factory_request(spec: dict[str, Any]) -> str: + title = str(spec.get("tool", {}).get("title") or "Client Intake Hub") + domain = str(spec.get("domain") or DEFAULT_DOMAIN) + return ( + f"Foundry create {domain} {title}: build a Cento-native internal consulting tool " + "with CRM state, intake workflow, no-build UI preview, Docs, storage/leak policy, " + "cost receipt, validation, and demo evidence." + ) + + +def run_factory_plan(run_dir: Path, spec: dict[str, Any]) -> dict[str, Any]: + factory_dir = run_dir / "factory" + request = factory_request(spec) + package = f"foundry-{slugify(spec.get('tool', {}).get('id', DEFAULT_FIXTURE))}-v1" + steps = [ + ( + "intake", + [ + sys.executable, + "scripts/factory.py", + "intake", + request, + "--dry-run", + "--out", + run_arg(factory_dir), + "--package", + package, + "--risk", + "medium", + "--json", + ], + ), + ("plan", [sys.executable, "scripts/factory.py", "plan", run_arg(factory_dir), "--no-model", "--json"]), + ("materialize", [sys.executable, "scripts/factory.py", "materialize", run_arg(factory_dir), "--json"]), + ("queue", [sys.executable, "scripts/factory.py", "queue", run_arg(factory_dir), "--json"]), + ("validate", [sys.executable, "scripts/factory.py", "validate", run_arg(factory_dir), "--json"]), + ] + receipts: list[dict[str, Any]] = [] + status = "passed" + for name, command in steps: + result = run_external(command) + acceptable_blocked_validation = ( + name == "validate" + and isinstance(result.payload, dict) + and result.payload.get("schema_version") == "factory-validation-summary/v1" + and result.payload.get("decision") == "blocked" + ) + receipt = {"step": name, "accepted_blocked_validation": acceptable_blocked_validation, **result.receipt()} + receipts.append(receipt) + write_json(run_dir / "receipts" / f"factory-{name}.json", receipt) + if result.returncode != 0 and not acceptable_blocked_validation: + status = "failed" + break + handoff = { + "schema_version": "cento.foundry.factory_handoff.v1", + "run_id": run_dir.name, + "status": status, + "factory_run_dir": rel(factory_dir), + "factory_plan": rel(factory_dir / "factory-plan.json"), + "receipts": [rel(run_dir / "receipts" / f"factory-{item['step']}.json") for item in receipts], + "written_at": now_iso(), + } + write_json(run_dir / "factory_handoff.json", handoff) + return handoff + + +def build_workset(run_dir: Path, spec: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + seeded = seed_client_intake_hub(run_dir, spec) + fixture_targets = client_intake_fixture_targets() + tasks = [ + { + "id": "crm-state-schema", + "worker_id": "crm_schema", + "task": "Refine Client Intake Hub CRM state schema fixture.", + "description": "Keep the generated profile schema aligned with career consulting intake and deliverables.", + "write_paths": [fixture_targets["schema"]], + "read_paths": [rel(run_dir / "foundry-spec.json"), rel(run_dir / "factory" / "factory-plan.json")], + "routes": ["crm", "factory"], + "depends_on": [], + }, + { + "id": "command-api", + "worker_id": "command_api", + "task": "Refine Client Intake Hub command API fixture.", + "description": "Keep the generated command map routed through existing Cento CRM and Foundry commands.", + "write_paths": [fixture_targets["commands"]], + "read_paths": [rel(run_dir / "foundry-spec.json")], + "routes": ["crm", "foundry"], + "depends_on": ["crm-state-schema"], + }, + { + "id": "ui-preview", + "worker_id": "ui_preview", + "task": "Refine Client Intake Hub no-build UI preview.", + "description": "Keep the generated preview usable for operator review without introducing a build stack.", + "write_paths": [fixture_targets["ui"]], + "read_paths": [rel(run_dir / "foundry-spec.json")], + "routes": ["crm", "docs"], + "depends_on": ["crm-state-schema"], + }, + { + "id": "docs-and-operator-notes", + "worker_id": "docs_operator", + "task": "Refine operator Docs for the Client Intake Hub fixture.", + "description": "Keep the generated Docs clear about fixture data, privacy, commands, and next steps.", + "write_paths": [fixture_targets["docs"]], + "read_paths": [rel(run_dir / "foundry-spec.json"), seeded["commands"]], + "routes": ["docs", "crm"], + "depends_on": ["command-api"], + }, + { + "id": "storage-leak-policy", + "worker_id": "storage_policy", + "task": "Refine Client Intake Hub storage and leak policy fixture.", + "description": "Keep private-by-default storage, OCI eligibility, and blocked data categories explicit.", + "write_paths": [fixture_targets["storage"]], + "read_paths": [rel(run_dir / "storage-policy.json")], + "routes": ["storage", "object-storage"], + "depends_on": ["crm-state-schema"], + }, + { + "id": "validation-demo-evidence", + "worker_id": "validation_demo", + "task": "Refine Client Intake Hub validation and demo evidence fixture.", + "description": "Keep validation and demo evidence paths aligned with the generated tool artifacts.", + "write_paths": [fixture_targets["validation"]], + "read_paths": [rel(run_dir / "demo-evidence.json"), rel(run_dir / "cost_receipt.json")], + "routes": ["demo-evidence", "validation"], + "depends_on": ["ui-preview", "docs-and-operator-notes", "storage-leak-policy"], + }, + ] + workset = { + "schema_version": "cento.workset.v1", + "id": f"foundry_{slugify(run_dir.name)}_{slugify(spec.get('tool', {}).get('id', DEFAULT_FIXTURE))}", + "mode": "fast", + "max_parallel": int(spec.get("max_parallel") or 6), + "tasks": tasks, + } + workset_path = run_dir / "workset.json" + write_json(workset_path, workset) + result = run_external([sys.executable, "scripts/cento_workset.py", "check", run_arg(workset_path), "--json"]) + check = result.payload if isinstance(result.payload, dict) else {"status": "failed", "errors": [result.stderr or result.stdout]} + write_json(run_dir / "workset_check.json", check) + write_json(run_dir / "receipts" / "workset-check.json", result.receipt()) + return workset, check + + +def command_plan(args: argparse.Namespace) -> int: + run_dir = resolve_run_dir(args.run_id, create=False) + spec = ensure_created(run_dir) + handoff = run_factory_plan(run_dir, spec) + workset, check = build_workset(run_dir, spec) + status = "planned" if handoff.get("status") == "passed" and check.get("status") == "passed" else "blocked" + receipt = { + "schema_version": SCHEMA_PLAN, + "run_id": run_dir.name, + "status": status, + "factory_handoff": rel(run_dir / "factory_handoff.json"), + "workset": rel(run_dir / "workset.json"), + "workset_check": rel(run_dir / "workset_check.json"), + "task_count": len(workset.get("tasks", [])), + "written_at": now_iso(), + } + write_json(run_dir / "plan_receipt.json", receipt) + write_summary(run_dir, status, "Factory handoff and Workset manifest generated.") + append_event(run_dir, "foundry_planned", {"status": status, "tasks": receipt["task_count"]}) + payload = {"status": status, "run_id": run_dir.name, "run_dir": rel(run_dir), **receipt} + if not getattr(args, "quiet", False): + output(payload, json_flag=args.json) + return 0 if status == "planned" else 1 + + +def require_live_budget(runtime: str, budget_usd: float | None, max_budget_usd: float | None) -> None: + if runtime != "api-openai": + return + if budget_usd is None or max_budget_usd is None: + raise SystemExit("live/api-openai Foundry execution requires both --budget-usd and --max-budget-usd") + if budget_usd <= 0 or max_budget_usd <= 0: + raise SystemExit("Foundry budgets must be positive") + if budget_usd > max_budget_usd: + raise SystemExit("--budget-usd must be less than or equal to --max-budget-usd") + if max_budget_usd > DEFAULT_MAX_BUDGET_USD: + raise SystemExit(f"Foundry v1 hard cap cannot exceed ${DEFAULT_MAX_BUDGET_USD:.0f}") + + +def train_run_id(run_dir: Path, explicit: str = "") -> str: + return explicit or f"foundry-{slugify(run_dir.name)}-train" + + +def read_train_cost(payload: dict[str, Any]) -> float: + train_run_dir = payload.get("run_dir") + if isinstance(train_run_dir, str) and train_run_dir: + receipt = read_json(repo_path(train_run_dir) / "train_receipt.json") + value = receipt.get("workset_total_cost_usd", receipt.get("total_cost_usd", 0.0)) + try: + return float(value or 0.0) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + +def command_execute(args: argparse.Namespace) -> int: + run_dir = resolve_run_dir(args.run_id, create=False) + spec = ensure_created(run_dir) + if not (run_dir / "workset.json").exists(): + plan_args = argparse.Namespace(run_id=args.run_id, json=False, quiet=True) + if command_plan(plan_args) != 0: + return 1 + runtime = str(args.runtime or "fixture") + require_live_budget(runtime, args.budget_usd, args.max_budget_usd) + train_id = train_run_id(run_dir, args.train_run_id) + command = [ + sys.executable, + "scripts/parallel_delivery.py", + "train", + "e2e", + "--workset", + run_arg(run_dir / "workset.json"), + "--max-parallel", + str(int(args.max_parallel or spec.get("max_parallel") or 6)), + "--runtime", + runtime, + "--validation", + str(args.validation or "smoke"), + "--allow-dirty-owned", + "--run-id", + train_id, + "--dry-run", + "--json", + ] + if runtime == "api-openai": + command.extend(["--budget-usd", str(args.budget_usd), "--max-budget-usd", str(args.max_budget_usd)]) + result = run_external(command) + write_json(run_dir / "train_e2e_result.json", result.receipt()) + payload = result.payload if isinstance(result.payload, dict) else {} + actual_cost = read_train_cost(payload) + write_cost_receipt( + run_dir, + mode="live" if runtime == "api-openai" else "dry-run", + runtime=runtime, + budget_usd=args.budget_usd, + max_budget_usd=args.max_budget_usd, + actual_cost_usd=actual_cost, + ai_calls_used=0 if runtime == "fixture" else -1, + ) + status = "completed" if result.returncode == 0 and payload.get("status") == "completed" else "blocked" + receipt = { + "schema_version": SCHEMA_EXECUTION, + "run_id": run_dir.name, + "status": status, + "runtime": runtime, + "train_run_id": train_id, + "train_run_dir": payload.get("run_dir", ""), + "train_manifest": payload.get("train_manifest", ""), + "workset_receipt": payload.get("workset_receipt", ""), + "validation": payload.get("validation", "unknown"), + "promotion": payload.get("promotion", "unknown"), + "factory_run_dir": payload.get("factory_run_dir", ""), + "cost_receipt": rel(run_dir / "cost_receipt.json"), + "written_at": now_iso(), + } + write_json(run_dir / "execution_receipt.json", receipt) + write_summary(run_dir, status, "Workset train executed and promoted through Factory dry-run handoff.") + append_event(run_dir, "foundry_executed", {"status": status, "runtime": runtime, "train_run_id": train_id}) + out = {"status": status, "run_id": run_dir.name, "run_dir": rel(run_dir), **receipt} + if not getattr(args, "quiet", False): + output(out, json_flag=args.json) + return 0 if status == "completed" else 1 + + +def command_promote(args: argparse.Namespace) -> int: + if args.apply and args.dry_run: + print("foundry promote accepts either --dry-run or --apply, not both.", file=sys.stderr) + return 2 + run_dir = resolve_run_dir(args.run_id, create=False) + execution = read_json(run_dir / "execution_receipt.json") + train_id = str(execution.get("train_run_id") or "") + if not train_id: + raise SystemExit("Foundry run has no execution_receipt.json with train_run_id") + command = [sys.executable, "scripts/parallel_delivery.py", "train", "promote", train_id, "--json"] + if args.apply: + command.append("--apply") + else: + command.append("--dry-run") + result = run_external(command) + write_json(run_dir / "promotion_receipt.json", result.receipt()) + payload = result.payload if isinstance(result.payload, dict) else {} + status = "completed" if result.returncode == 0 and payload.get("status") in {"planned", "completed"} else "blocked" + append_event(run_dir, "foundry_promoted", {"status": status, "train_run_id": train_id}) + out = {"status": status, "run_id": run_dir.name, "run_dir": rel(run_dir), "promotion_receipt": rel(run_dir / "promotion_receipt.json"), **payload} + output(out, json_flag=args.json) + return 0 if status == "completed" else 1 + + +def command_materialize(args: argparse.Namespace) -> int: + if args.apply and args.dry_run: + print("foundry materialize accepts either --dry-run or --apply, not both.", file=sys.stderr) + return 2 + run_dir = resolve_run_dir(args.run_id, create=False) + spec = ensure_created(run_dir) + try: + target_root = normalize_real_file_target_root(args.target_root) + except ValueError as exc: + print(f"foundry materialize: {exc}", file=sys.stderr) + return 2 + apply_files = bool(args.apply) + try: + receipt = materialize_real_files(run_dir, spec, target_root=target_root, apply=apply_files) + except (OSError, ValueError) as exc: + print(f"foundry materialize: {exc}", file=sys.stderr) + return 1 + append_event( + run_dir, + "foundry_materialized", + {"status": receipt["status"], "mode": receipt["mode"], "target_root": target_root}, + ) + payload = {"run_id": run_dir.name, "run_dir": rel(run_dir), "materialization_receipt": rel(run_dir / "materialization_receipt.json"), **receipt} + if not getattr(args, "quiet", False): + output(payload, json_flag=args.json) + return 0 if receipt["status"] in {"planned", "materialized"} else 1 + + +def validate_run(run_dir: Path) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + + def add_check(name: str, passed: bool, detail: str = "") -> None: + checks.append({"name": name, "passed": bool(passed), "detail": detail}) + + spec = read_json(run_dir / "foundry-spec.json") + workset_check = read_json(run_dir / "workset_check.json") + execution = read_json(run_dir / "execution_receipt.json") + cost = read_json(run_dir / "cost_receipt.json") + storage = read_json(run_dir / "storage-policy.json") + demo = read_json(run_dir / "demo-evidence.json") + real_file_manifest = read_json(run_dir / "real_file_manifest.json") + materialization = read_json(run_dir / "materialization_receipt.json") + + add_check("foundry spec", spec.get("schema_version") == SCHEMA_SPEC, rel(run_dir / "foundry-spec.json")) + add_check("factory handoff", (run_dir / "factory_handoff.json").exists(), rel(run_dir / "factory_handoff.json")) + add_check("workset manifest", (run_dir / "workset.json").exists(), rel(run_dir / "workset.json")) + add_check("workset check passed", workset_check.get("status") == "passed", str(workset_check.get("errors") or "")) + add_check("execution completed", execution.get("status") == "completed", str(execution.get("train_run_id") or "")) + add_check("train validation passed", execution.get("validation") == "passed", str(execution.get("validation") or "unknown")) + add_check("factory promotion ready", str(execution.get("promotion") or "") in {"ready_for_apply", "applied"}, str(execution.get("promotion") or "unknown")) + add_check("cost receipt", cost.get("schema_version") == SCHEMA_COST and not bool(cost.get("hard_cap_exceeded")), rel(run_dir / "cost_receipt.json")) + add_check("storage private by default", storage.get("public_access") == "blocked", rel(run_dir / "storage-policy.json")) + add_check("demo evidence", demo.get("schema_version") == SCHEMA_DEMO, rel(run_dir / "demo-evidence.json")) + if real_file_manifest or materialization: + materialized_status = materialization.get("status") + add_check("real-file manifest", real_file_manifest.get("schema_version") == SCHEMA_REAL_FILE_MANIFEST, rel(run_dir / "real_file_manifest.json")) + add_check( + "materialization receipt", + materialization.get("schema_version") == SCHEMA_MATERIALIZATION_RECEIPT and materialized_status in {"planned", "materialized"}, + str(materialized_status or "missing"), + ) + add_check( + "real-file privacy", + real_file_manifest.get("privacy", {}).get("contains_real_client_data") is False + and real_file_manifest.get("privacy", {}).get("cloud_upload_allowed") is False, + rel(run_dir / "real_file_manifest.json"), + ) + + status = "passed" if all(item["passed"] for item in checks) else "failed" + summary = { + "schema_version": SCHEMA_VALIDATION, + "run_id": run_dir.name, + "status": status, + "checks": checks, + "written_at": now_iso(), + } + write_json(run_dir / "validation_summary.json", summary) + write_text( + run_dir / "validation_summary.md", + "\n".join( + ["# Foundry Validation", "", f"- Run: `{run_dir.name}`", f"- Status: `{status}`", "", "## Checks", ""] + + [f"- [{'x' if item['passed'] else ' '}] {item['name']}: {item['detail']}" for item in checks] + ), + ) + return summary + + +def command_validate(args: argparse.Namespace) -> int: + run_dir = resolve_run_dir(args.run_id, create=False) + summary = validate_run(run_dir) + append_event(run_dir, "foundry_validated", {"status": summary["status"]}) + payload = {"run_id": run_dir.name, "run_dir": rel(run_dir), **summary} + output(payload, json_flag=args.json) + return 0 if summary["status"] == "passed" else 1 + + +def command_status(args: argparse.Namespace) -> int: + run_dir = resolve_run_dir(args.run_id, create=False) + spec = read_json(run_dir / "foundry-spec.json") + plan = read_json(run_dir / "plan_receipt.json") + execution = read_json(run_dir / "execution_receipt.json") + validation = read_json(run_dir / "validation_summary.json") + cost = read_json(run_dir / "cost_receipt.json") + materialization = read_json(run_dir / "materialization_receipt.json") + payload = { + "status": validation.get("status") or execution.get("status") or plan.get("status") or ("created" if spec else "missing"), + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "tool": spec.get("tool", {}).get("id", ""), + "domain": spec.get("domain", ""), + "plan": plan.get("status", "unknown"), + "execution": execution.get("status", "unknown"), + "validation": validation.get("status", "unknown"), + "materialization": materialization.get("status", "not_run"), + "materialization_receipt": rel(run_dir / "materialization_receipt.json") if materialization else "", + "actual_cost_usd": cost.get("actual_cost_usd", 0), + "max_budget_usd": cost.get("max_budget_usd", 0), + "summary": rel(run_dir / "summary.md") if (run_dir / "summary.md").exists() else "", + } + output(payload, json_flag=args.json) + return 0 + + +def command_e2e(args: argparse.Namespace) -> int: + if args.live and args.dry_run: + print("foundry e2e accepts either --dry-run or --live, not both.", file=sys.stderr) + return 2 + if args.materialize_apply and not args.real_files: + print("foundry e2e --materialize-apply requires --real-files.", file=sys.stderr) + return 2 + runtime = "api-openai" if args.live else "fixture" + require_live_budget(runtime, args.budget_usd, args.max_budget_usd) + idea = args.idea or "client intake hub" + create_args = argparse.Namespace( + idea=idea, + domain=args.domain, + fixture=args.fixture, + run_id=args.run_id or default_run_id(idea, args.domain), + out=args.out, + max_parallel=args.max_parallel, + budget_usd=args.budget_usd, + max_budget_usd=args.max_budget_usd, + json=False, + quiet=True, + ) + if command_create(create_args) != 0: + return 1 + run_dir = repo_path(args.out) if args.out else resolve_run_dir(create_args.run_id) + if command_plan(argparse.Namespace(run_id=run_arg(run_dir), json=False, quiet=True)) != 0: + return 1 + execute_code = command_execute( + argparse.Namespace( + run_id=run_arg(run_dir), + runtime=runtime, + train_run_id="", + budget_usd=args.budget_usd, + max_budget_usd=args.max_budget_usd, + max_parallel=args.max_parallel, + validation=args.validation, + json=False, + quiet=True, + ) + ) + validation: dict[str, Any] = {"status": "not_run"} + materialization: dict[str, Any] = {"status": "not_run"} + if execute_code == 0: + validation = validate_run(run_dir) + append_event(run_dir, "foundry_e2e_validated", {"status": validation["status"]}) + if execute_code == 0 and validation.get("status") == "passed" and args.real_files: + materialize_args = argparse.Namespace( + run_id=run_arg(run_dir), + target_root=args.target_root, + dry_run=not bool(args.materialize_apply), + apply=bool(args.materialize_apply), + json=False, + quiet=True, + ) + materialize_code = command_materialize(materialize_args) + materialization = read_json(run_dir / "materialization_receipt.json") + if materialize_code == 0: + validation = validate_run(run_dir) + append_event(run_dir, "foundry_e2e_materialized", {"status": materialization.get("status", "unknown")}) + else: + validation = {"status": "failed"} + status = ( + "passed" + if execute_code == 0 + and validation.get("status") == "passed" + and (not args.real_files or materialization.get("status") in {"planned", "materialized"}) + else "failed" + ) + payload = { + "status": status, + "mode": "live" if args.live else "dry-run", + "real_files": bool(args.real_files), + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "validation": validation.get("status", "not_run"), + "materialization": materialization.get("status", "not_run"), + "foundry_spec": rel(run_dir / "foundry-spec.json"), + "execution_receipt": rel(run_dir / "execution_receipt.json"), + "validation_summary": rel(run_dir / "validation_summary.json"), + "cost_receipt": rel(run_dir / "cost_receipt.json"), + "materialization_receipt": rel(run_dir / "materialization_receipt.json") if (run_dir / "materialization_receipt.json").exists() else "", + } + output(payload, json_flag=args.json) + return 0 if status == "passed" else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create Cento-native business tools through Factory, Workset, train, storage, and evidence gates.") + sub = parser.add_subparsers(dest="command", required=True) + + create = sub.add_parser("create", help="Create a Foundry run spec and seed fixture tool artifacts.") + create.add_argument("idea") + create.add_argument("--domain", default=DEFAULT_DOMAIN) + create.add_argument("--fixture", default=DEFAULT_FIXTURE, choices=[DEFAULT_FIXTURE]) + create.add_argument("--run-id", default="") + create.add_argument("--out", default="") + create.add_argument("--max-parallel", type=int, default=6) + create.add_argument("--budget-usd", type=float, default=None) + create.add_argument("--max-budget-usd", type=float, default=None) + create.add_argument("--json", action="store_true") + create.set_defaults(func=command_create) + + plan = sub.add_parser("plan", help="Generate Factory handoff, Workset manifest, and validation-ready plan receipt.") + plan.add_argument("run_id") + plan.add_argument("--json", action="store_true") + plan.set_defaults(func=command_plan) + + execute = sub.add_parser("execute", help="Execute the generated Workset through parallel-delivery train e2e.") + execute.add_argument("run_id") + execute.add_argument("--runtime", choices=["fixture", "api-openai"], default="fixture") + execute.add_argument("--train-run-id", default="") + execute.add_argument("--budget-usd", type=float, default=None) + execute.add_argument("--max-budget-usd", type=float, default=None) + execute.add_argument("--max-parallel", type=int, default=0) + execute.add_argument("--validation", default="smoke") + execute.add_argument("--json", action="store_true") + execute.set_defaults(func=command_execute) + + promote = sub.add_parser("promote", help="Re-run Factory promotion for the Foundry train run.") + promote.add_argument("run_id") + promote.add_argument("--dry-run", action="store_true") + promote.add_argument("--apply", action="store_true") + promote.add_argument("--json", action="store_true") + promote.set_defaults(func=command_promote) + + materialize = sub.add_parser("materialize", help="Plan or apply repo-ready files from a Foundry run.") + materialize.add_argument("run_id") + materialize.add_argument("--target-root", default=DEFAULT_REAL_FILE_TARGET_ROOT) + materialize_mode = materialize.add_mutually_exclusive_group() + materialize_mode.add_argument("--dry-run", action="store_true") + materialize_mode.add_argument("--apply", action="store_true") + materialize.add_argument("--json", action="store_true") + materialize.set_defaults(func=command_materialize) + + status = sub.add_parser("status", help="Show Foundry run status.") + status.add_argument("run_id") + status.add_argument("--json", action="store_true") + status.set_defaults(func=command_status) + + validate = sub.add_parser("validate", help="Validate required Foundry receipts and gates.") + validate.add_argument("run_id") + validate.add_argument("--json", action="store_true") + validate.set_defaults(func=command_validate) + + e2e = sub.add_parser("e2e", help="Create, plan, execute, promote, and validate a fixture Foundry run.") + e2e.add_argument("--fixture", default=DEFAULT_FIXTURE, choices=[DEFAULT_FIXTURE]) + e2e.add_argument("--idea", default="client intake hub") + e2e.add_argument("--domain", default=DEFAULT_DOMAIN) + e2e.add_argument("--run-id", default="") + e2e.add_argument("--out", default="") + e2e.add_argument("--max-parallel", type=int, default=6) + mode = e2e.add_mutually_exclusive_group() + mode.add_argument("--dry-run", action="store_true") + mode.add_argument("--live", action="store_true") + e2e.add_argument("--real-files", action="store_true", help="Plan real repo files after the fixture train validates.") + e2e.add_argument("--target-root", default=DEFAULT_REAL_FILE_TARGET_ROOT, help="Repo-relative target root for --real-files.") + e2e.add_argument("--materialize-apply", action="store_true", help="Apply real files during --real-files e2e instead of dry-run planning.") + e2e.add_argument("--budget-usd", type=float, default=None) + e2e.add_argument("--max-budget-usd", type=float, default=None) + e2e.add_argument("--validation", default="smoke") + e2e.add_argument("--json", action="store_true") + e2e.set_defaults(func=command_e2e) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tool_index.py b/scripts/tool_index.py index b8067f0..3ef4eda 100755 --- a/scripts/tool_index.py +++ b/scripts/tool_index.py @@ -37,6 +37,11 @@ def main() -> int: lines.append("- commands:") for command in commands: lines.append(f" - `{command}`") + docs = tool.get("docs", []) + if docs: + lines.append("- docs:") + for doc in docs: + lines.append(f" - [`{doc}`](./{Path(doc).name})") lines.append("") output.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/validation_manifest.py b/scripts/validation_manifest.py index 7ee9376..e4a1b8c 100644 --- a/scripts/validation_manifest.py +++ b/scripts/validation_manifest.py @@ -148,8 +148,9 @@ def output_check(item: dict[str, Any], story: dict[str, Any]) -> dict[str, Any] return None return { "name": str(item.get("name") or check_name("file-exists", Path(path).name)), - "type": "file_exists", + "type": "file", "path": path, + "non_empty": True, "required": bool(item.get("required", True)), } @@ -162,8 +163,9 @@ def screenshot_checks(item: dict[str, Any], story: dict[str, Any]) -> list[dict[ return [ { "name": check_name("screenshot-exists", name), - "type": "file_exists", + "type": "file", "path": output, + "non_empty": True, "required": bool(item.get("required", True)), }, { diff --git a/scripts/walk_autopilot.py b/scripts/walk_autopilot.py new file mode 100644 index 0000000..4190fc9 --- /dev/null +++ b/scripts/walk_autopilot.py @@ -0,0 +1,5212 @@ +#!/usr/bin/env python3 +"""Run the append-only Walk Autopilot follow-up loop.""" + +from __future__ import annotations + +import argparse +from collections import Counter +import hashlib +import json +import os +import shlex +import shutil +import sqlite3 +import subprocess +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import spend_ledger + + +ROOT = Path(__file__).resolve().parents[1] +RUN_ROOT = ROOT / "workspace" / "runs" / "walk-autopilot" +ROUTING_RUN_ROOT = RUN_ROOT / "routing-native" +ROUTING_LATEST_DIR = ROUTING_RUN_ROOT / "latest" +REVIEW_UNBLOCK_RUN_ROOT = RUN_ROOT / "review-unblock" +REVIEW_UNBLOCK_LATEST_DIR = REVIEW_UNBLOCK_RUN_ROOT / "latest" +PATCH_SWARM_AUTOPILOT_ROOT = RUN_ROOT / "patch-swarm" +PATCH_SWARM_AUTOPILOT_LATEST_DIR = PATCH_SWARM_AUTOPILOT_ROOT / "latest" +STATE_DIR = Path.home() / ".local" / "state" / "cento" +FACTORY_SCALE_CRON_BEGIN = "# BEGIN CENTO FACTORY SCALE FINAL TEST" +FACTORY_SCALE_CRON_END = "# END CENTO FACTORY SCALE FINAL TEST" +FACTORY_SCALE_LOG_PATH = ROOT / "workspace" / "logs" / "factory-scale-final-test.log" +FACTORY_SCALE_ROADMAP_DOC = ROOT / "docs" / "factory-1000-patch-swarm-roadmap.md" +FACTORY_SCALE_ADVANCE_DIRNAME = "advance" +FACTORY_SCALE_ACTIVE_STATUSES = {"planned", "running"} +LIVE_API_LOCK_NAME = "openai-live-api.lock" +ROUTING_CRON_BEGIN = "# BEGIN CENTO ROUTING NATIVE LOOP" +ROUTING_CRON_END = "# END CENTO ROUTING NATIVE LOOP" +ROUTING_LOG_PATH = ROOT / "workspace" / "logs" / "routing-native-loop.log" +ROUTING_AGENT_WORK_PACKAGE = "cento-routing-nativeness" +REVIEW_UNBLOCK_ACTION_CAPS = { + "close_done": 20, + "validate_local": 4, + "dispatch_validator": 3, + "requeue_stale_dispatch": 6, + "repair_task": 3, + "close_demo_test": 10, + "archive_stale_historical": 6, +} +REVIEW_UNBLOCK_ACTIVE_RUN_STATUSES = {"planned", "launching", "running"} +REVIEW_UNBLOCK_MUTATING_TYPES = { + "close_done", + "validate_local", + "dispatch_validator", + "requeue_stale_dispatch", + "repair_task", + "close_demo_test", + "archive_stale_historical", +} +SKILL_TERMS = [ + "cento-native", + "ui-verify-and-report", + "cento-requirements-manifest", + "navigate-skills", + "openai-docs", + "imagegen", + "plugin-creator", + "skill-creator", + "skill-installer", +] +REQUIRED_LOOP_SECTIONS = [ + "Findings", + "Breakthroughs", + "Copied-Forward Notes", + "Next Steps", + "Next Big Things", + "Spend", + "Validation", + "Changed Files", + "Blockers", + "Recommended Next Loop", +] +DASHBOARD_TOTAL_ENV = "CENTO_OPENAI_DASHBOARD_TOTAL_SPEND_USD" +OPENAI_HARD_CAP_ENV = "CENTO_OPENAI_HARD_CAP_USD" +REQUIRE_DASHBOARD_BUDGET_ENV = "CENTO_REQUIRE_DASHBOARD_TOTAL_BUDGET" +FACTORY_SCALE_PROREQ_COMMANDS = [ + ("intake", ["./scripts/cento.sh", "proreq-light", "intake"]), + ("context", ["./scripts/cento.sh", "proreq-light", "context"]), + ("screenshot", ["./scripts/cento.sh", "proreq-light", "screenshot"]), + ("pro-request", ["./scripts/cento.sh", "proreq-light", "pro-request"]), + ("codex-plan", ["./scripts/cento.sh", "proreq-light", "codex-plan"]), + ("backend-work", ["./scripts/cento.sh", "proreq-light", "backend-work"]), + ("integration-plan", ["./scripts/cento.sh", "proreq-light", "integration-plan"]), + ("validation-plan", ["./scripts/cento.sh", "proreq-light", "validation-plan"]), + ("deliver", ["./scripts/cento.sh", "proreq-light", "deliver", "--no-full-check", "--json"]), + ("evidence", ["./scripts/cento.sh", "proreq-light", "evidence"]), +] +FACTORY_SCALE_MILESTONE_SPECS = [ + { + "id": "milestone-01", + "title": "Coordinator kernel, cron, append-only ledgers", + "executions": [ + ("coordinator-kernel", "Define the factory-scale coordinator kernel and run contract."), + ("cron-deadline-lock", "Install deadline-aware cron with flock overlap prevention."), + ("append-only-ledgers", "Prove events, calls, metrics, and spend ledgers are append-only."), + ], + }, + { + "id": "milestone-02", + "title": "ProReq-light batch runner and isolated run roots", + "executions": [ + ("batch-runner", "Select exactly one pending ProReq-light execution per tick."), + ("isolated-run-roots", "Keep each ProReq-light pipeline root away from the active Dev Pipeline Studio root."), + ("call-ledger-contract", "Record ten explicit ProReq-light command calls per execution."), + ], + }, + { + "id": "milestone-03", + "title": "Patch Swarm ingestion from ProReq-light outputs", + "executions": [ + ("proreq-output-ingestion", "Normalize ProReq-light outputs into Patch Swarm milestone handoffs."), + ("milestone-grouping", "Bind every three ProReq-light executions to one Patch Swarm run."), + ("candidate-receipt-linking", "Link generated candidate receipts back to their ProReq-light inputs."), + ], + }, + { + "id": "milestone-04", + "title": "Provider adapters for Codex/Claude/API candidate receipts", + "executions": [ + ("codex-candidate-adapter", "Shape Codex Exec patch proposals into candidate_patch.v1 receipts."), + ("claude-candidate-adapter", "Shape Claude Code proposals into the same provider-neutral receipt."), + ("api-candidate-adapter", "Keep OpenAI API candidates behind explicit budget gates."), + ], + }, + { + "id": "milestone-05", + "title": "Deterministic validation fanout and failure taxonomy", + "executions": [ + ("validator-fanout", "Run deterministic validation across candidate receipts."), + ("failure-taxonomy", "Classify schema, ownership, patch-shape, duplicate, and test failures."), + ("quarantine-ledger", "Append rejected candidates and reasons without mutating accepted evidence."), + ], + }, + { + "id": "milestone-06", + "title": "Manifest-driven Safe Integrator queue", + "executions": [ + ("integrator-queue", "Queue selected winners for the Factory Safe Integrator."), + ("worktree-apply-plan", "Require apply through Factory/Safe Integrator worktrees only."), + ("rollback-receipts", "Attach rollback and validation receipts to every integration plan."), + ], + }, + { + "id": "milestone-07", + "title": "Cost/latency admission controller", + "executions": [ + ("cost-admission", "Reject live provider fanout without explicit budget and hard cap."), + ("latency-budget", "Track seconds per candidate, selected patch, and validation tier."), + ("duplicate-saturation", "Stop candidate generation when duplicate clusters saturate."), + ], + }, + { + "id": "milestone-08", + "title": "Dev Pipeline / Factory operator observability", + "executions": [ + ("operator-status", "Render log-derived status for the six-hour run."), + ("factory-ui-state", "Expose candidate counts, provider mix, and handoffs to Dev Pipeline state."), + ("handoff-evidence", "Keep operator handoff markdown current as a derived artifact."), + ], + }, + { + "id": "milestone-09", + "title": "Self-improvement task generator", + "executions": [ + ("improvement-miner", "Mine failure taxonomy and metrics for self-improvement tasks."), + ("task-generator", "Draft bounded Agent Work follow-ups for repeated blockers."), + ("promotion-gates", "Promote only improvements with passing deterministic validation."), + ], + }, + { + "id": "milestone-10", + "title": "1,000-patch Factory pilot and scale report", + "executions": [ + ("thousand-candidate-pilot", "Complete ten fixture Patch Swarm runs for 1,000 candidates."), + ("scale-report", "Summarize cost, latency, validation, and integration readiness."), + ("repeat-loop", "Feed the next self-improvement loop from the scale report."), + ], + }, +] + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def parse_iso_datetime(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) + except ValueError: + return None + + +def timestamp_id(prefix: str = "walk-autopilot") -> str: + return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def resolve_cento_path(value: str | Path) -> Path: + path = Path(str(value)).expanduser() + return path if path.is_absolute() else ROOT / path + + +def read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, IsADirectoryError, OSError): + return {} + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def append_jsonl(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") + + +def optional_float(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, str) and not value.strip(): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def dashboard_total_spend_usd(args: argparse.Namespace) -> float | None: + explicit = optional_float(getattr(args, "dashboard_total_spend_usd", None)) + if explicit is not None: + return explicit + return optional_float(os.environ.get(DASHBOARD_TOTAL_ENV)) + + +def live_api_budget_gate(args: argparse.Namespace, summary: dict[str, Any] | None = None) -> dict[str, Any]: + dashboard_total = dashboard_total_spend_usd(args) + hard_cap = float(getattr(args, "hard_cap_usd", 20.0)) + local_total = float((summary or {}).get("total_cost_usd") or 0.0) + effective_total = max(local_total, dashboard_total or 0.0) + if not bool(getattr(args, "allow_live_api", False)): + return { + "allowed": True, + "status": "not-required", + "dashboard_total_spend_usd": dashboard_total, + "local_total_cost_usd": round(local_total, 8), + "effective_total_cost_usd": round(effective_total, 8), + "hard_cap_usd": hard_cap, + } + if dashboard_total is None: + return { + "allowed": False, + "status": "blocked", + "reason": f"--allow-live-api requires --dashboard-total-spend-usd or {DASHBOARD_TOTAL_ENV}; dashboard total is the hard-cap source of truth.", + "dashboard_total_spend_usd": None, + "local_total_cost_usd": round(local_total, 8), + "effective_total_cost_usd": round(effective_total, 8), + "hard_cap_usd": hard_cap, + } + if dashboard_total >= hard_cap: + return { + "allowed": False, + "status": "blocked", + "reason": f"dashboard total ${dashboard_total:.2f} is already >= hard cap ${hard_cap:.2f}", + "dashboard_total_spend_usd": round(dashboard_total, 8), + "local_total_cost_usd": round(local_total, 8), + "effective_total_cost_usd": round(effective_total, 8), + "hard_cap_usd": hard_cap, + } + if effective_total >= hard_cap: + return { + "allowed": False, + "status": "blocked", + "reason": f"effective spend ${effective_total:.2f} is already >= hard cap ${hard_cap:.2f}", + "dashboard_total_spend_usd": round(dashboard_total, 8), + "local_total_cost_usd": round(local_total, 8), + "effective_total_cost_usd": round(effective_total, 8), + "hard_cap_usd": hard_cap, + } + return { + "allowed": True, + "status": "allowed", + "dashboard_total_spend_usd": round(dashboard_total, 8), + "local_total_cost_usd": round(local_total, 8), + "effective_total_cost_usd": round(effective_total, 8), + "hard_cap_usd": hard_cap, + } + + +def run_command(command: list[str], *, timeout: int, env: dict[str, str] | None = None) -> dict[str, Any]: + started = time.monotonic() + try: + result = subprocess.run( + command, + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + return { + "command": command, + "command_text": shlex.join(command), + "exit_code": result.returncode, + "stdout_tail": (result.stdout or "")[-6000:], + "stderr_tail": (result.stderr or "")[-6000:], + "duration_seconds": round(time.monotonic() - started, 3), + "timed_out": False, + } + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout if isinstance(exc.stdout, str) else "" + stderr = exc.stderr if isinstance(exc.stderr, str) else "" + return { + "command": command, + "command_text": shlex.join(command), + "exit_code": 124, + "stdout_tail": stdout[-6000:], + "stderr_tail": (stderr + f"\ntimeout after {timeout}s")[-6000:], + "duration_seconds": round(time.monotonic() - started, 3), + "timed_out": True, + } + + +def init_run(run_dir: Path, args: argparse.Namespace) -> dict[str, Any]: + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "loops").mkdir(exist_ok=True) + notes = run_dir / "notes.md" + if not notes.exists(): + notes.write_text( + "# Walk Autopilot Notes\n\n" + "- Factory dry-run cost must remain separated from explicit Pro/image/API cost.\n" + "- Dispatch preflight remains enabled; missing canonical story manifests should be repaired before launch attempts.\n", + encoding="utf-8", + ) + for filename in ("metrics.jsonl", "spend-ledger.jsonl"): + (run_dir / filename).touch(exist_ok=True) + config = { + "schema_version": "cento.walk_autopilot.config.v1", + "run_id": run_dir.name, + "created_at": now_iso(), + "loops": args.loops, + "cadence_seconds": args.cadence_seconds, + "soft_cap_usd": args.soft_cap_usd, + "hard_cap_usd": args.hard_cap_usd, + "live_workers": bool(args.live_workers), + "allow_live_api": bool(args.allow_live_api), + "max_worker_launch": args.max_worker_launch, + "review_unblock_mode": review_unblock_mode_for_args(args), + "patch_swarm_enabled": bool(getattr(args, "patch_swarm", False)), + "patch_swarm_candidate_target": int(getattr(args, "patch_swarm_candidate_target", 100)), + "budget_scope": "openai_dashboard_total_for_live_api", + "dashboard_total_spend_usd": dashboard_total_spend_usd(args), + "compute_policy": {"codex": 85, "claude": 15, "openai_api": 0}, + } + write_json(run_dir / "config.json", config) + dashboard_total = dashboard_total_spend_usd(args) + if dashboard_total is not None: + spend_ledger.append_record( + run_dir / "spend-ledger.jsonl", + spend_ledger.build_dashboard_total_record( + run_id=run_dir.name, + total_usd=dashboard_total, + note="Operator-supplied OpenAI dashboard total spend snapshot for live API hard-cap gating.", + ), + ) + if args.dashboard_delta_usd: + spend_ledger.append_record( + run_dir / "spend-ledger.jsonl", + spend_ledger.build_dashboard_delta_record( + run_id=run_dir.name, + delta_usd=args.dashboard_delta_usd, + note="Operator-supplied dashboard delta for reconciliation.", + ), + ) + return config + + +def previous_loop_path(run_dir: Path, loop_number: int) -> Path | None: + if loop_number <= 1: + return None + path = run_dir / "loops" / f"loop-{loop_number - 1:04d}.md" + return path if path.exists() else None + + +def copied_forward_notes(run_dir: Path, loop_number: int) -> str: + previous = previous_loop_path(run_dir, loop_number) + notes = (run_dir / "notes.md").read_text(encoding="utf-8") if (run_dir / "notes.md").exists() else "" + copied = ["Current notes.md:", notes.strip() or "- No prior notes."] + if previous: + text = previous.read_text(encoding="utf-8") + marker = "## Recommended Next Loop" + if marker in text: + copied.append("Previous recommended next loop:\n" + text.split(marker, 1)[1].strip()[:2000]) + return "\n\n".join(copied) + + +def spend_summary(run_dir: Path) -> dict[str, Any]: + return spend_ledger.summarize_paths([run_dir / "spend-ledger.jsonl"]) + + +def hard_cap_reached(summary: dict[str, Any], hard_cap: float) -> bool: + return float(summary.get("total_cost_usd") or 0.0) >= hard_cap + + +def validation_green(commands: list[dict[str, Any]]) -> bool: + required = ["tools-json", "compute-policy", "factory-status", "factory-autopilot", "parallel-delivery-validate", "agent-pool-dry-run"] + by_name = {str(item.get("name") or ""): item for item in commands} + for name in required: + if name not in by_name: + return False + exit_code = by_name[name].get("exit_code") + if exit_code is None or int(exit_code) != 0: + return False + return True + + +def count_dirty_files() -> int: + result = run_command(["git", "status", "--short"], timeout=20) + if result["exit_code"] != 0: + return -1 + return len([line for line in str(result.get("stdout_tail") or "").splitlines() if line.strip()]) + + +def command_record(name: str, result: dict[str, Any]) -> dict[str, Any]: + return {"name": name, **result} + + +def command_json_payload(record: dict[str, Any]) -> dict[str, Any]: + text = str(record.get("stdout_tail") or "").strip() + if not text: + return {} + try: + payload = json.loads(text) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8", errors="ignore")).hexdigest() + + +def file_fingerprint(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"exists": False, "path": rel(path)} + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + stat = path.stat() + return { + "exists": True, + "path": rel(path), + "bytes": stat.st_size, + "sha256": digest.hexdigest(), + "mtime": int(stat.st_mtime), + } + + +def read_crontab(crontab_file: str = "") -> str: + if crontab_file: + try: + return Path(crontab_file).read_text(encoding="utf-8") + except FileNotFoundError: + return "" + try: + result = subprocess.run(["crontab", "-l"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + except OSError: + return "" + if result.returncode != 0: + return "" + return result.stdout + + +def write_crontab(text: str, crontab_file: str = "") -> None: + if crontab_file: + path = Path(crontab_file) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return + result = subprocess.run(["crontab", "-"], input=text, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "crontab install failed") + + +def strip_routing_cron_block(text: str) -> str: + if ROUTING_CRON_BEGIN not in text: + return text + before, rest = text.split(ROUTING_CRON_BEGIN, 1) + if ROUTING_CRON_END not in rest: + return before.rstrip() + "\n" + _block, after = rest.split(ROUTING_CRON_END, 1) + return (before.rstrip() + "\n" + after.lstrip()).strip() + ("\n" if before.strip() or after.strip() else "") + + +def routing_cron_block(every_hours: int) -> str: + if every_hours < 1 or every_hours > 24: + raise ValueError("--every-hours must be between 1 and 24") + schedule = f"0 */{every_hours} * * *" + inner = f"cd {shlex.quote(str(ROOT))} && ./scripts/cento.sh walk-autopilot routing run --json" + command = ( + f"mkdir -p {shlex.quote(str(STATE_DIR))} {shlex.quote(str(ROUTING_LOG_PATH.parent))} " + f"&& flock -n {shlex.quote(str(STATE_DIR / 'routing-native-loop.lock'))} " + f"bash -lc {shlex.quote(inner)} >> {shlex.quote(str(ROUTING_LOG_PATH))} 2>&1" + ) + return "\n".join([ROUTING_CRON_BEGIN, f"{schedule} {command}", ROUTING_CRON_END, ""]) + + +def routing_cron_status(crontab_file: str = "") -> dict[str, Any]: + text = read_crontab(crontab_file) + installed = ROUTING_CRON_BEGIN in text and ROUTING_CRON_END in text + schedule = "" + if installed: + block = text.split(ROUTING_CRON_BEGIN, 1)[1].split(ROUTING_CRON_END, 1)[0] + for line in block.splitlines(): + line = line.strip() + if line: + schedule = " ".join(line.split()[:5]) + break + return { + "installed": installed, + "marker_begin_count": text.count(ROUTING_CRON_BEGIN), + "marker_end_count": text.count(ROUTING_CRON_END), + "schedule": schedule, + "log_path": rel(ROUTING_LOG_PATH), + "crontab_file": crontab_file, + } + + +def strip_factory_scale_cron_block(text: str) -> str: + if FACTORY_SCALE_CRON_BEGIN not in text: + return text + before, rest = text.split(FACTORY_SCALE_CRON_BEGIN, 1) + if FACTORY_SCALE_CRON_END not in rest: + return before.rstrip() + "\n" + _block, after = rest.split(FACTORY_SCALE_CRON_END, 1) + return (before.rstrip() + "\n" + after.lstrip()).strip() + ("\n" if before.strip() or after.strip() else "") + + +def factory_scale_run_dir(run_id: str) -> Path: + return RUN_ROOT / run_id + + +def resolve_artifact_path(value: Any) -> Path: + text = str(value or "").strip() + if not text: + return ROOT + path = Path(text) + return path if path.is_absolute() else ROOT / path + + +def latest_factory_scale_run_dir() -> Path | None: + if not RUN_ROOT.exists(): + return None + runs = [path for path in RUN_ROOT.glob("factory-scale-*") if path.is_dir()] + return max(runs, key=lambda path: (path.stat().st_mtime, path.name)) if runs else None + + +def factory_scale_slug(index: int) -> tuple[str, str]: + flat: list[tuple[str, str]] = [] + for milestone in FACTORY_SCALE_MILESTONE_SPECS: + flat.extend([(str(slug), str(title)) for slug, title in milestone["executions"]]) + if not flat: + return f"exec-{index:03d}", "Factory scale ProReq-light execution" + slug, title = flat[(index - 1) % len(flat)] + if index > len(flat): + cycle = ((index - 1) // len(flat)) + 1 + slug = f"{slug}-cycle-{cycle}" + title = f"{title} (cycle {cycle})" + return slug, title + + +def ceil_div(value: int, divisor: int) -> int: + return (max(0, int(value)) + max(1, int(divisor)) - 1) // max(1, int(divisor)) + + +def factory_scale_executions_for_call_target(target_proreq_calls: int) -> int: + return max(1, ceil_div(int(target_proreq_calls), len(FACTORY_SCALE_PROREQ_COMMANDS))) + + +def factory_scale_call_target_for_executions(proreq_executions: int) -> int: + return max(1, int(proreq_executions)) * len(FACTORY_SCALE_PROREQ_COMMANDS) + + +def factory_scale_manifest( + proreq_executions: int, + *, + patch_swarm: bool = True, + candidate_target: int = 100, + max_parallel_agents: int = 5, +) -> dict[str, Any]: + total = max(1, int(proreq_executions)) + candidate_target = max(1, int(candidate_target)) + max_parallel_agents = max(1, int(max_parallel_agents)) + executions: list[dict[str, Any]] = [] + for index in range(1, total + 1): + slug, title = factory_scale_slug(index) + milestone_index = ((index - 1) // 3) + 1 + milestone_spec = FACTORY_SCALE_MILESTONE_SPECS[(milestone_index - 1) % len(FACTORY_SCALE_MILESTONE_SPECS)] + executions.append( + { + "id": f"exec-{index:03d}", + "index": index, + "slug": slug, + "title": title, + "milestone_id": f"milestone-{milestone_index:02d}", + "milestone_title": str(milestone_spec["title"]), + "expected_command_count": len(FACTORY_SCALE_PROREQ_COMMANDS), + } + ) + milestones: list[dict[str, Any]] = [] + for start in range(0, len(executions), 3): + group = executions[start : start + 3] + if not group: + continue + milestone_index = (start // 3) + 1 + spec = FACTORY_SCALE_MILESTONE_SPECS[(milestone_index - 1) % len(FACTORY_SCALE_MILESTONE_SPECS)] + milestones.append( + { + "id": f"milestone-{milestone_index:02d}", + "index": milestone_index, + "title": str(spec["title"]), + "proreq_execution_ids": [str(item["id"]) for item in group], + "patch_swarm_enabled": bool(patch_swarm), + "patch_swarm_trigger_after": str(group[-1]["id"]) if len(group) == 3 else "", + "candidate_target": candidate_target, + "max_parallel_agents": max_parallel_agents, + } + ) + return { + "schema_version": "cento.walk_autopilot.factory_scale.manifest.v1", + "proreq_execution_count": len(executions), + "expected_proreq_call_count": len(executions) * len(FACTORY_SCALE_PROREQ_COMMANDS), + "patch_swarm_milestone_count": len([item for item in milestones if item.get("patch_swarm_trigger_after")]), + "expected_candidate_receipts": len([item for item in milestones if item.get("patch_swarm_trigger_after") and patch_swarm]) * candidate_target, + "executions": executions, + "milestones": milestones, + } + + +def factory_scale_roadmap_markdown(manifest: dict[str, Any]) -> str: + lines = [ + "# Factory 1,000 Patch Swarm Roadmap", + "", + "This roadmap scales Cento Factory toward `1,000 parallel candidate patches -> manifest-driven integration -> mostly deterministic validation -> task done in seconds for $1-2 -> self-improve and repeat`.", + "", + "The six-hour final test uses local, API-safe defaults: ProReq-light command calls are ledgered in isolated run roots, Patch Swarm runs fixture/candidate-receipt e2e, and any real apply remains behind Factory/Safe Integrator worktrees.", + "", + "## Milestones", + "", + ] + executions_by_milestone: dict[str, list[dict[str, Any]]] = {} + for execution in as_list(manifest.get("executions")): + if isinstance(execution, dict): + executions_by_milestone.setdefault(str(execution.get("milestone_id") or ""), []).append(execution) + for milestone in as_list(manifest.get("milestones")): + if not isinstance(milestone, dict): + continue + lines.extend( + [ + f"### {milestone.get('index')}. {milestone.get('title')}", + "", + ] + ) + for execution in executions_by_milestone.get(str(milestone.get("id") or ""), []): + lines.append(f"- `{execution.get('id')}` `{execution.get('slug')}`: {execution.get('title')}") + lines.extend( + [ + "", + f"Patch Swarm: `{'enabled' if milestone.get('patch_swarm_enabled') else 'disabled'}` after `{milestone.get('patch_swarm_trigger_after') or 'not enough executions'}` with `100` candidate receipts.", + "", + ] + ) + lines.extend( + [ + "## Final Test Contract", + "", + "- Duration: six hours by default, scheduled every 12 minutes for 30 ticks.", + "- ProReq-light: 30 executions, 10 command-call records each, 300 command-call ledger records total.", + "- Patch Swarm: 10 fixture e2e sub-executions, 100 candidate receipts each, 1,000 candidate receipts total.", + "- Mutation policy: no direct main-worktree apply; selected candidates hand off to Factory/Safe Integrator.", + "- Hard stops: deadline reached, repeated cron lock conflict, two consecutive infrastructure failures, unexpected live API request, or untracked dirty growth without a matching ledger event.", + "", + ] + ) + return "\n".join(lines) + + +def factory_scale_execution_prompt(execution: dict[str, Any]) -> str: + return "\n".join( + [ + f"Factory scale final test ProReq-light execution `{execution.get('id')}`.", + f"Milestone: {execution.get('milestone_title')}", + f"Task: {execution.get('title')}", + "", + "Generate planning artifacts only. Keep live OpenAI API and image dispatch disabled. Any patch apply must go through Factory/Safe Integrator worktrees.", + ] + ) + + +def factory_scale_seed_proreq_root(run_dir: Path, execution: dict[str, Any]) -> Path: + execution_dir = run_dir / "proreq-executions" / str(execution["id"]) + pipeline_root = execution_dir / "pipeline-root" + payload = { + "schema_version": "cento.pipeline.execution_run.v1", + "run_id": str(execution["id"]), + "project_id": "proreq-light-project", + "template_id": "proreq-light-task", + "pipeline": "proreq-light-task-proreq-light-project", + "source": "cento-walk-autopilot-factory-scale", + "status": "running", + "prompt": factory_scale_execution_prompt(execution), + "issue_subject": str(execution.get("title") or execution.get("slug") or execution["id"]), + "triggered_by": "walk-autopilot-factory-scale", + "inputs": [ + {"id": "operator-thoughts", "kind": "questionnaire", "source": "user", "answer": factory_scale_execution_prompt(execution)}, + {"id": "generated-cento-context", "kind": "path", "source": "auto"}, + {"id": "ui-screenshot-request", "kind": "image", "source": "auto", "automation": "request-only"}, + {"id": "pro-backend-schema", "kind": "details", "source": "auto"}, + {"id": "backend-work-handoff", "kind": "evidence", "source": "auto"}, + ], + } + execution_run_path = pipeline_root / "execution" / "execution_run.json" + execution_manifest_path = execution_dir / "execution.json" + if not execution_run_path.exists(): + write_json(execution_run_path, payload) + if not execution_manifest_path.exists(): + write_json(execution_manifest_path, {"schema_version": "cento.factory_scale.proreq_execution.v1", **execution, "pipeline_root": rel(pipeline_root)}) + return pipeline_root + + +def factory_scale_append_event(run_dir: Path, event: str, payload: dict[str, Any] | None = None) -> None: + append_jsonl( + run_dir / "events.jsonl", + { + "schema_version": "cento.walk_autopilot.factory_scale.event.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "event": event, + **(payload or {}), + }, + ) + + +def factory_scale_append_thought(run_dir: Path, thought: str, payload: dict[str, Any] | None = None) -> None: + append_jsonl( + run_dir / "thoughts.jsonl", + { + "schema_version": "cento.walk_autopilot.factory_scale.thought.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "thought": thought, + **(payload or {}), + }, + ) + + +def factory_scale_init_run(run_dir: Path, args: argparse.Namespace) -> dict[str, Any]: + run_dir.mkdir(parents=True, exist_ok=True) + patch_swarm_candidate_target = int(getattr(args, "patch_swarm_candidate_target", 100)) + patch_swarm_max_parallel_agents = int(getattr(args, "patch_swarm_max_parallel_agents", 5)) + manifest = factory_scale_manifest( + args.proreq_executions, + patch_swarm=bool(args.patch_swarm), + candidate_target=patch_swarm_candidate_target, + max_parallel_agents=patch_swarm_max_parallel_agents, + ) + started = datetime.now(timezone.utc).replace(microsecond=0) + deadline = started + timedelta(hours=float(args.duration_hours)) + tick_schedule = str(getattr(args, "tick_schedule", "*/12 * * * *") or "*/12 * * * *") + batch_size = max(1, int(getattr(args, "batch_size", 1))) + target_proreq_calls = int(getattr(args, "target_proreq_calls", manifest["expected_proreq_call_count"]) or manifest["expected_proreq_call_count"]) + max_proreq_calls = int(getattr(args, "max_proreq_calls", manifest["expected_proreq_call_count"]) or manifest["expected_proreq_call_count"]) + config = { + "schema_version": "cento.walk_autopilot.factory_scale.config.v1", + "run_id": run_dir.name, + "created_at": started.isoformat().replace("+00:00", "Z"), + "deadline_at": deadline.isoformat().replace("+00:00", "Z"), + "duration_hours": float(args.duration_hours), + "tick_schedule": tick_schedule, + "batch_size": batch_size, + "run_mode": str(getattr(args, "run_mode", "final-test") or "final-test"), + "lock_name": str(getattr(args, "lock_name", "factory-scale-final-test.lock") or "factory-scale-final-test.lock"), + "target_proreq_calls": target_proreq_calls, + "max_proreq_calls": max_proreq_calls, + "proreq_executions": int(args.proreq_executions), + "min_proreq_calls": int(args.min_proreq_calls), + "expected_proreq_calls": manifest["expected_proreq_call_count"], + "patch_swarm_enabled": bool(args.patch_swarm), + "patch_swarm_candidate_target": patch_swarm_candidate_target, + "patch_swarm_max_parallel_agents": patch_swarm_max_parallel_agents, + "patch_swarm_expected_runs": manifest["patch_swarm_milestone_count"] if bool(args.patch_swarm) else 0, + "patch_swarm_expected_candidate_receipts": manifest["expected_candidate_receipts"] if bool(args.patch_swarm) else 0, + "execute_proreq": bool(getattr(args, "execute_proreq", False)), + "proreq_command_timeout": int(getattr(args, "proreq_command_timeout", 900)), + "proreq_light_mode": "local-codex-exec" if bool(getattr(args, "execute_proreq", False)) else "ledger-only-api-safe", + "hard_stop_conditions": [ + "deadline reached", + "cron lock conflict lasting more than one tick", + "two consecutive infrastructure failures", + "unexpected live API request", + "untracked dirty growth without a matching ledger event", + ], + "roadmap_doc": rel(FACTORY_SCALE_ROADMAP_DOC), + } + write_json(run_dir / "config.json", config) + write_json(run_dir / "execution-manifest.json", manifest) + (run_dir / "roadmap.md").write_text(factory_scale_roadmap_markdown(manifest), encoding="utf-8") + for filename in ("events.jsonl", "thoughts.jsonl", "proreq-light-calls.jsonl", "metrics.jsonl", "spend-ledger.jsonl"): + (run_dir / filename).touch(exist_ok=True) + for execution in as_list(manifest.get("executions")): + if isinstance(execution, dict): + factory_scale_seed_proreq_root(run_dir, execution) + for milestone in as_list(manifest.get("milestones")): + if isinstance(milestone, dict): + milestone_dir = run_dir / "patch-swarm" / str(milestone["id"]) + milestone_dir.mkdir(parents=True, exist_ok=True) + write_json(milestone_dir / "milestone.json", {"schema_version": "cento.factory_scale.patch_swarm_milestone.v1", **milestone}) + factory_scale_append_event(run_dir, "run_started", {"config": rel(run_dir / "config.json"), "manifest": rel(run_dir / "execution-manifest.json")}) + factory_scale_append_thought( + run_dir, + "Factory scale final test initialized with log-derived status and isolated ProReq-light roots.", + {"expected_proreq_calls": config["expected_proreq_calls"], "expected_candidate_receipts": config["patch_swarm_expected_candidate_receipts"]}, + ) + factory_scale_write_handoff(run_dir) + return config + + +def factory_scale_completed_execution_ids(run_dir: Path) -> set[str]: + calls = spend_ledger.read_jsonl(run_dir / "proreq-light-calls.jsonl") + completed = { + str(item.get("execution_id") or "") + for item in calls + if str(item.get("command_name") or "") == "evidence" and str(item.get("status") or "") in {"logged", "completed"} + } + return {item for item in completed if item} + + +def factory_scale_next_execution(run_dir: Path) -> dict[str, Any] | None: + manifest = read_json(run_dir / "execution-manifest.json") + completed = factory_scale_completed_execution_ids(run_dir) + for execution in as_list(manifest.get("executions")): + if isinstance(execution, dict) and str(execution.get("id") or "") not in completed: + return execution + return None + + +def factory_scale_consecutive_infra_failures(events: list[dict[str, Any]]) -> int: + count = 0 + for item in reversed(events): + event = str(item.get("event") or "") + if event in {"proreq_execution_failed", "patch_swarm_failed", "cron_lock_conflict"}: + count += 1 + continue + if event in {"proreq_execution_completed", "patch_swarm_completed", "run_started", "hard_stop"}: + break + return count + + +def factory_scale_record_proreq_call( + run_dir: Path, + execution: dict[str, Any], + *, + command_index: int, + command_name: str, + command: list[str], + pipeline_root: Path, + result: dict[str, Any] | None = None, +) -> None: + result = result or {} + status = str(result.get("status") or "logged") + append_jsonl( + run_dir / "proreq-light-calls.jsonl", + { + "schema_version": "cento.walk_autopilot.factory_scale.proreq_light_call.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "execution_id": str(execution["id"]), + "execution_index": int(execution.get("index") or 0), + "milestone_id": str(execution.get("milestone_id") or ""), + "command_index": command_index, + "command_name": command_name, + "command": command, + "command_text": shlex.join(command), + "pipeline_root": rel(pipeline_root), + "status": status, + "exit_code": result.get("exit_code", 0), + "duration_seconds": result.get("duration_seconds", 0), + "stdout_tail": str(result.get("stdout_tail") or "")[-1000:], + "stderr_tail": str(result.get("stderr_tail") or "")[-1000:], + "mode": result.get("mode", "ledger-only"), + }, + ) + + +def factory_scale_run_proreq_execution(run_dir: Path, execution: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]: + pipeline_root = factory_scale_seed_proreq_root(run_dir, execution) + execute = bool(config.get("execute_proreq")) + failures: list[str] = [] + for command_index, (command_name, command) in enumerate(FACTORY_SCALE_PROREQ_COMMANDS, start=1): + if execute: + env = os.environ.copy() + env["CENTO_DEV_PIPELINE_STUDIO_ROOT"] = str(pipeline_root) + env["CENTO_WALK_AUTOPILOT_RUN_DIR"] = str(run_dir) + env.setdefault("CENTO_HARD_PROREQ_DISABLE_GPT_IMAGE_2", "1") + result = run_command(command, timeout=int(config.get("proreq_command_timeout") or 900), env=env) + result["status"] = "completed" if int(result.get("exit_code") or 0) == 0 else "failed" + result["mode"] = "local-codex-exec" + else: + result = { + "status": "logged", + "exit_code": 0, + "duration_seconds": 0, + "stdout_tail": "", + "stderr_tail": "", + "mode": "ledger-only", + } + receipt_path = run_dir / "proreq-executions" / str(execution["id"]) / f"call-{command_index:02d}-{command_name}.json" + write_json( + receipt_path, + { + "schema_version": "cento.factory_scale.proreq_light_call_receipt.v1", + "run_id": run_dir.name, + "execution_id": str(execution["id"]), + "command_index": command_index, + "command_name": command_name, + "command": command, + "status": "logged", + "pipeline_root": rel(pipeline_root), + "api_safe": True, + }, + ) + factory_scale_record_proreq_call( + run_dir, + execution, + command_index=command_index, + command_name=command_name, + command=command, + pipeline_root=pipeline_root, + result=result, + ) + if int(result.get("exit_code") or 0) != 0: + failures.append(f"{command_name}: exit {result.get('exit_code')}") + break + status = "completed" if not failures else "failed" + event = "proreq_execution_completed" if status == "completed" else "proreq_execution_failed" + factory_scale_append_event( + run_dir, + event, + { + "execution_id": str(execution["id"]), + "execution_index": int(execution.get("index") or 0), + "milestone_id": str(execution.get("milestone_id") or ""), + "status": status, + "call_count": len(FACTORY_SCALE_PROREQ_COMMANDS) if not failures else len(spend_ledger.read_jsonl(run_dir / "proreq-light-calls.jsonl")), + "failures": failures, + "pipeline_root": rel(pipeline_root), + }, + ) + return {"status": status, "failures": failures, "pipeline_root": rel(pipeline_root)} + + +def factory_scale_milestone_for_execution(run_dir: Path, execution: dict[str, Any]) -> dict[str, Any]: + manifest = read_json(run_dir / "execution-manifest.json") + milestone_id = str(execution.get("milestone_id") or "") + for milestone in as_list(manifest.get("milestones")): + if isinstance(milestone, dict) and str(milestone.get("id") or "") == milestone_id: + return milestone + return {} + + +def factory_scale_patch_swarm_already_ran(run_dir: Path, milestone_id: str) -> bool: + for item in spend_ledger.read_jsonl(run_dir / "events.jsonl"): + if str(item.get("event") or "") == "patch_swarm_completed" and str(item.get("milestone_id") or "") == milestone_id: + return True + return False + + +def factory_scale_run_patch_swarm_milestone(run_dir: Path, milestone: dict[str, Any]) -> dict[str, Any]: + milestone_id = str(milestone.get("id") or "") + milestone_dir = run_dir / "patch-swarm" / milestone_id + milestone_dir.mkdir(parents=True, exist_ok=True) + swarm_run_id = f"{run_dir.name}-{milestone_id}" + command = [ + "./scripts/cento.sh", + "parallel-delivery", + "patch-swarm", + "e2e", + "--run-id", + swarm_run_id, + "--candidate-target", + str(int(milestone.get("candidate_target") or 100)), + "--max-parallel-agents", + str(int(milestone.get("max_parallel_agents") or 5)), + "--fixture", + "--json", + ] + result = run_command(command, timeout=360) + record = command_record("factory-scale-patch-swarm", result) + write_json(milestone_dir / "command_result.json", record) + try: + payload = json.loads(str(result.get("stdout_tail") or "{}")) + except json.JSONDecodeError: + payload = {} + status = "completed" if int(result.get("exit_code") or 0) == 0 and payload.get("status") == "completed" else "failed" + summary = { + "schema_version": "cento.factory_scale.patch_swarm_milestone_summary.v1", + "run_id": run_dir.name, + "milestone_id": milestone_id, + "status": status, + "parallel_delivery_run_id": swarm_run_id, + "parallel_delivery_run": payload.get("run_dir") or f"workspace/runs/parallel-delivery/patch-swarm/{swarm_run_id}", + "proreq_execution_ids": milestone.get("proreq_execution_ids", []), + "candidate_count": int(payload.get("candidate_count") or 0), + "selected_count": int(payload.get("selected_count") or 0), + "estimated_cost_usd": float(payload.get("estimated_cost_usd") or 0.0), + "safe_integrator_handoff": payload.get("safe_integrator_handoff", ""), + "validation": payload.get("validation", "unknown"), + "decision_report": payload.get("decision_report", ""), + } + write_json(milestone_dir / "summary.json", summary) + (milestone_dir / "handoff.md").write_text( + "\n".join( + [ + f"# Factory Scale {milestone_id} Patch Swarm Handoff", + "", + f"- Status: `{summary['status']}`", + f"- ProReq-light inputs: `{', '.join([str(item) for item in summary['proreq_execution_ids']])}`", + f"- Candidate receipts: `{summary['candidate_count']}`", + f"- Selected candidates: `{summary['selected_count']}`", + f"- Parallel delivery run: `{summary['parallel_delivery_run']}`", + f"- Safe Integrator handoff: `{summary['safe_integrator_handoff'] or '-'}`", + "", + "Mutation policy: selected patches remain candidate receipts until Factory/Safe Integrator worktrees validate and apply them.", + "", + ] + ), + encoding="utf-8", + ) + factory_scale_append_event( + run_dir, + "patch_swarm_completed" if status == "completed" else "patch_swarm_failed", + { + "milestone_id": milestone_id, + "status": status, + "candidate_count": summary["candidate_count"], + "selected_count": summary["selected_count"], + "parallel_delivery_run": summary["parallel_delivery_run"], + "safe_integrator_handoff": summary["safe_integrator_handoff"], + }, + ) + append_jsonl( + run_dir / "spend-ledger.jsonl", + { + "schema_version": "cento.factory_scale.spend_ledger.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "lane": "patch-swarm", + "category": "fixture-estimate", + "provider": "local-fixture", + "billable": False, + "cost_usd": 0.0, + "estimated_cost_usd": summary["estimated_cost_usd"], + "candidate_count": summary["candidate_count"], + "milestone_id": milestone_id, + "artifact": rel(milestone_dir / "summary.json"), + "note": "Patch Swarm fixture e2e writes candidate receipts and Safe Integrator handoff; no live API spend.", + }, + ) + return summary + + +def factory_scale_status_payload(run_id: str = "", crontab_file: str = "") -> dict[str, Any]: + run_dir = factory_scale_run_dir(run_id) if run_id else latest_factory_scale_run_dir() + if not run_dir or not run_dir.exists(): + return {"schema_version": "cento.walk_autopilot.factory_scale.status.v1", "status": "unknown", "run_id": run_id, "run_dir": ""} + config = read_json(run_dir / "config.json") + manifest = read_json(run_dir / "execution-manifest.json") + events = spend_ledger.read_jsonl(run_dir / "events.jsonl") + calls = spend_ledger.read_jsonl(run_dir / "proreq-light-calls.jsonl") + metrics_records = spend_ledger.read_jsonl(run_dir / "metrics.jsonl") + completed = factory_scale_completed_execution_ids(run_dir) + patch_events = [item for item in events if str(item.get("event") or "") == "patch_swarm_completed"] + hard_stops = [item for item in events if str(item.get("event") or "") == "hard_stop"] + deadline = parse_iso_datetime(config.get("deadline_at")) + deadline_reached = bool(deadline and datetime.now(timezone.utc) >= deadline) + expected_execs = int(config.get("proreq_executions") or manifest.get("proreq_execution_count") or 0) + expected_calls = int(config.get("expected_proreq_calls") or manifest.get("expected_proreq_call_count") or 0) + expected_patch_runs = int(config.get("patch_swarm_expected_runs") or 0) + candidate_receipts = sum(int(item.get("candidate_count") or 0) for item in patch_events) + if hard_stops: + status = "stopped" + elif expected_execs and len(completed) >= expected_execs and len(patch_events) >= expected_patch_runs: + status = "completed" + elif deadline_reached: + status = "deadline_reached" + elif events: + status = "running" + else: + status = "planned" + next_execution = factory_scale_next_execution(run_dir) + cron = factory_scale_cron_status(crontab_file) + return { + "schema_version": "cento.walk_autopilot.factory_scale.status.v1", + "checked_at": now_iso(), + "status": status, + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "run_mode": str(config.get("run_mode") or "final-test"), + "deadline_at": config.get("deadline_at"), + "deadline_reached": deadline_reached, + "cron": cron, + "batch_size": int(config.get("batch_size") or 1), + "target_proreq_calls": int(config.get("target_proreq_calls") or expected_calls), + "max_proreq_calls": int(config.get("max_proreq_calls") or expected_calls), + "proreq_execution_count": expected_execs, + "completed_proreq_executions": len(completed), + "pending_proreq_executions": max(0, expected_execs - len(completed)), + "remaining_proreq_calls": max(0, expected_calls - len(calls)), + "next_execution_id": str(next_execution.get("id") or "") if next_execution else "", + "proreq_call_count": len(calls), + "expected_proreq_call_count": expected_calls, + "min_proreq_calls": int(config.get("min_proreq_calls") or 0), + "min_proreq_calls_met": len(calls) >= int(config.get("min_proreq_calls") or 0), + "patch_swarm_runs": len(patch_events), + "expected_patch_swarm_runs": expected_patch_runs, + "candidate_patch_receipts": candidate_receipts, + "expected_candidate_patch_receipts": int(config.get("patch_swarm_expected_candidate_receipts") or 0), + "hard_stop_count": len(hard_stops), + "metrics_records": len(metrics_records), + "handoff": rel(run_dir / "handoff.md"), + } + + +def factory_scale_write_handoff(run_dir: Path) -> None: + status = factory_scale_status_payload(run_dir.name) + lines = [ + "# Factory Scale Final Test Handoff", + "", + f"- Run: `{run_dir.name}`", + f"- Mode: `{status.get('run_mode')}`", + f"- Status: `{status.get('status')}`", + f"- Deadline: `{status.get('deadline_at')}`", + f"- Batch size: `{status.get('batch_size')}`", + f"- ProReq-light executions: `{status.get('completed_proreq_executions')}/{status.get('proreq_execution_count')}`", + f"- ProReq-light command calls: `{status.get('proreq_call_count')}/{status.get('expected_proreq_call_count')}`", + f"- Remaining ProReq-light command calls: `{status.get('remaining_proreq_calls')}`", + f"- Patch Swarm runs: `{status.get('patch_swarm_runs')}/{status.get('expected_patch_swarm_runs')}`", + f"- Candidate patch receipts: `{status.get('candidate_patch_receipts')}/{status.get('expected_candidate_patch_receipts')}`", + f"- Next execution: `{status.get('next_execution_id') or '-'}`", + "", + "## Resume", + "", + f"`./scripts/cento.sh walk-autopilot factory-scale tick --run-id {run_dir.name} --batch-size {status.get('batch_size') or 1} --json`", + "", + "## Status", + "", + f"`./scripts/cento.sh walk-autopilot factory-scale status --run-id {run_dir.name} --json`", + "", + "## Mutation Policy", + "", + "Patch candidates remain receipts until Factory/Safe Integrator worktrees validate and apply them. The factory-scale coordinator does not apply patches to the main worktree.", + "", + ] + (run_dir / "handoff.md").write_text("\n".join(lines), encoding="utf-8") + + +def factory_scale_append_metrics(run_dir: Path, payload: dict[str, Any]) -> None: + status = factory_scale_status_payload(run_dir.name) + append_jsonl( + run_dir / "metrics.jsonl", + { + "schema_version": "cento.walk_autopilot.factory_scale.metrics.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "status": status.get("status"), + "completed_proreq_executions": status.get("completed_proreq_executions"), + "proreq_call_count": status.get("proreq_call_count"), + "patch_swarm_runs": status.get("patch_swarm_runs"), + "candidate_patch_receipts": status.get("candidate_patch_receipts"), + **payload, + }, + ) + + +def factory_scale_candidate_matrix(run_dir: Path, *, promotion_limit: int = 25) -> dict[str, Any]: + status = factory_scale_status_payload(run_dir.name) + manifest = read_json(run_dir / "execution-manifest.json") + candidates: list[dict[str, Any]] = [] + selected: list[dict[str, Any]] = [] + milestones: list[dict[str, Any]] = [] + provider_counts: Counter[str] = Counter() + selected_provider_counts: Counter[str] = Counter() + status_counts: Counter[str] = Counter() + touched_path_counts: Counter[str] = Counter() + error_counts: Counter[str] = Counter() + missing_artifacts: list[str] = [] + total_estimated_cost = 0.0 + + for milestone in as_list(manifest.get("milestones")): + if not isinstance(milestone, dict): + continue + milestone_id = str(milestone.get("id") or "") + summary = read_json(run_dir / "patch-swarm" / milestone_id / "summary.json") + parallel_run = resolve_artifact_path(summary.get("parallel_delivery_run")) if summary else Path("") + candidate_index = read_json(parallel_run / "candidate_index.json") if summary else {} + ranking = read_json(parallel_run / "ranking.json") if summary else {} + handoff_path = resolve_artifact_path(summary.get("safe_integrator_handoff")) if summary else Path("") + handoff = read_json(handoff_path) if summary else {} + milestone_candidates = [item for item in as_list(candidate_index.get("candidates")) if isinstance(item, dict)] + handoff_selected = [item for item in as_list(handoff.get("selected_candidates")) if isinstance(item, dict)] + selected_ids = {str(item.get("candidate_id") or "") for item in handoff_selected} + candidate_lookup: dict[str, dict[str, Any]] = {} + if not summary: + missing_artifacts.append(rel(run_dir / "patch-swarm" / milestone_id / "summary.json")) + if summary and not candidate_index: + missing_artifacts.append(rel(parallel_run / "candidate_index.json")) + if summary and not handoff: + missing_artifacts.append(rel(handoff_path)) + for item in milestone_candidates: + candidate_id = str(item.get("id") or "") + provider = str(item.get("provider") or "unknown") + state = str(item.get("status") or "unknown") + patch = as_dict(item.get("patch")) + touched = [str(path) for path in as_list(item.get("touched_paths"))] + row = { + "milestone_id": milestone_id, + "milestone_title": str(milestone.get("title") or ""), + "parallel_delivery_run": rel(parallel_run), + "candidate_id": candidate_id, + "execution_id": str(item.get("execution_id") or ""), + "task_id": str(item.get("task_id") or ""), + "provider": provider, + "status": state, + "score": float(item.get("score") or 0.0), + "cost_usd_estimate": float(item.get("cost_usd_estimate") or 0.0), + "duration_ms_estimate": int(item.get("duration_ms_estimate") or 0), + "touched_paths": touched, + "patch_file": str(patch.get("patch_file") or ""), + "patch_sha256": str(patch.get("sha256") or ""), + "candidate_receipt": str(item.get("candidate_receipt") or ""), + "validation_receipt": str(item.get("validation_receipt") or ""), + "selected": candidate_id in selected_ids, + "errors": [str(error) for error in as_list(item.get("errors"))], + } + candidates.append(row) + candidate_lookup[candidate_id] = row + provider_counts[provider] += 1 + status_counts[state] += 1 + total_estimated_cost += row["cost_usd_estimate"] + for path in touched: + touched_path_counts[path] += 1 + for error in row["errors"]: + error_counts[error or "unknown"] += 1 + for item in handoff_selected: + provider = str(item.get("provider") or "unknown") + candidate_id = str(item.get("candidate_id") or "") + detail = candidate_lookup.get(candidate_id, {}) + selected_row = { + "milestone_id": milestone_id, + "milestone_title": str(milestone.get("title") or ""), + "parallel_delivery_run": rel(parallel_run), + "handoff": rel(handoff_path), + "candidate_id": candidate_id, + "execution_id": str(item.get("execution_id") or ""), + "provider": provider, + "score": float(item.get("score") or 0.0), + "cost_usd_estimate": float(detail.get("cost_usd_estimate") or 0.0), + "duration_ms_estimate": int(detail.get("duration_ms_estimate") or 0), + "patch_file": str(item.get("patch_file") or ""), + "candidate_receipt": str(detail.get("candidate_receipt") or ""), + "validation_receipt": str(detail.get("validation_receipt") or ""), + "touched_paths": [str(path) for path in as_list(item.get("touched_paths"))], + } + selected.append(selected_row) + selected_provider_counts[provider] += 1 + milestones.append( + { + "milestone_id": milestone_id, + "title": str(milestone.get("title") or ""), + "status": str(summary.get("status") or "missing"), + "parallel_delivery_run": rel(parallel_run) if summary else "", + "candidate_count": len(milestone_candidates), + "selected_count": len(handoff_selected), + "top_candidate_count": len(as_list(ranking.get("top_candidates"))), + "safe_integrator_handoff": rel(handoff_path) if summary else "", + "proreq_execution_ids": [str(item) for item in as_list(milestone.get("proreq_execution_ids"))], + } + ) + + selected.sort(key=lambda item: (-float(item.get("score") or 0.0), float(item.get("cost_usd_estimate") or 0.0), str(item.get("milestone_id") or ""), str(item.get("candidate_id") or ""))) + promotion_candidates = selected[: max(0, int(promotion_limit))] + return { + "schema_version": "cento.walk_autopilot.factory_scale.candidate_matrix.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "source_status": status, + "candidate_count": len(candidates), + "selected_count": len(selected), + "promotion_limit": int(promotion_limit), + "promotion_candidate_count": len(promotion_candidates), + "estimated_provider_cost_usd": round(total_estimated_cost, 6), + "provider_counts": dict(sorted(provider_counts.items())), + "selected_provider_counts": dict(sorted(selected_provider_counts.items())), + "status_counts": dict(sorted(status_counts.items())), + "top_touched_paths": [{"path": path, "count": count} for path, count in touched_path_counts.most_common(20)], + "validation_taxonomy": { + "status_counts": dict(sorted(status_counts.items())), + "error_counts": dict(sorted(error_counts.items())), + "missing_artifacts": missing_artifacts, + }, + "milestones": milestones, + "selected_candidates": selected, + "promotion_candidates": promotion_candidates, + "candidates": candidates, + } + + +def factory_scale_promotion_plan(matrix: dict[str, Any], live_api_guard: dict[str, Any]) -> dict[str, Any]: + plans: list[dict[str, Any]] = [] + for index, candidate in enumerate(as_list(matrix.get("promotion_candidates")), start=1): + if not isinstance(candidate, dict): + continue + parallel_run = str(candidate.get("parallel_delivery_run") or "") + plans.append( + { + "sequence": index, + "milestone_id": str(candidate.get("milestone_id") or ""), + "candidate_id": str(candidate.get("candidate_id") or ""), + "execution_id": str(candidate.get("execution_id") or ""), + "provider": str(candidate.get("provider") or ""), + "score": float(candidate.get("score") or 0.0), + "cost_usd_estimate": float(candidate.get("cost_usd_estimate") or 0.0), + "duration_ms_estimate": int(candidate.get("duration_ms_estimate") or 0), + "patch_file": str(candidate.get("patch_file") or ""), + "candidate_receipt": str(candidate.get("candidate_receipt") or ""), + "validation_receipt": str(candidate.get("validation_receipt") or ""), + "touched_paths": [str(path) for path in as_list(candidate.get("touched_paths"))], + "safe_integrator_handoff": str(candidate.get("handoff") or ""), + "dry_run_command": f"./scripts/cento.sh parallel-delivery patch-swarm integrate {Path(parallel_run).name} --dry-run --json" if parallel_run else "", + "validation_command": f"./scripts/cento.sh parallel-delivery patch-swarm validate {Path(parallel_run).name} --json" if parallel_run else "", + } + ) + return { + "schema_version": "cento.walk_autopilot.factory_scale.safe_integrator_promotion_plan.v1", + "written_at": now_iso(), + "run_id": str(matrix.get("run_id") or ""), + "status": "ready" if plans else "blocked", + "apply": False, + "dry_run": True, + "factory_safe_integrator_required": True, + "promotion_limit": int(matrix.get("promotion_limit") or 0), + "candidate_receipts_considered": int(matrix.get("candidate_count") or 0), + "selected_candidates_available": int(matrix.get("selected_count") or 0), + "promotion_plan_count": len(plans), + "live_api_guard": { + "live_api_requested": bool(live_api_guard.get("live_api_requested")), + "live_api_enabled": bool(live_api_guard.get("live_api_enabled")), + "fail_closed": bool(live_api_guard.get("fail_closed")), + "blocked_reasons": as_list(live_api_guard.get("blocked_reasons")), + }, + "plans": plans, + "next_gate": "Factory/Safe Integrator worktree apply plan and deterministic validation; no direct main-worktree mutation.", + } + + +def factory_scale_safe_id(value: str) -> str: + return "".join(ch.lower() if ch.isalnum() else "-" for ch in str(value)).strip("-") or "factory-scale" + + +def factory_scale_normalize_promotion_candidate(run_dir: Path, item: dict[str, Any], sequence: int) -> dict[str, Any]: + import parallel_delivery + + milestone_id = str(item.get("milestone_id") or "milestone") + source_execution_id = str(item.get("execution_id") or f"exec-{sequence:03d}") + candidate_id = str(item.get("candidate_id") or f"factory-scale-candidate-{sequence:04d}") + task_id = factory_scale_safe_id(f"{milestone_id}-{source_execution_id}-{sequence:04d}") + touched_paths = [str(path) for path in as_list(item.get("touched_paths")) if str(path)] + patch_file = str(item.get("patch_file") or "") + patch_path = resolve_cento_path(patch_file) if patch_file else Path("") + patch_sha = file_fingerprint(patch_path).get("sha256", "") if patch_file else "" + return { + "schema_version": parallel_delivery.SCHEMA_PATCH_SWARM_CANDIDATE, + "id": candidate_id, + "run_id": run_dir.name, + "execution_id": task_id, + "source_execution_id": source_execution_id, + "task_id": task_id, + "provider": str(item.get("provider") or "codex-exec"), + "status": "validated", + "score": float(item.get("score") or 0.0), + "cost_usd_estimate": float(item.get("cost_usd_estimate") or 0.0), + "duration_ms_estimate": int(item.get("duration_ms_estimate") or 0), + "touched_paths": touched_paths, + "owned_paths": touched_paths, + "patch": { + "format": "unified_diff", + "patch_file": patch_file, + "sha256": str(item.get("patch_sha256") or patch_sha or ""), + }, + "candidate_receipt": str(item.get("candidate_receipt") or ""), + "validation_receipt": str(item.get("validation_receipt") or ""), + "errors": [], + "promotion_source": { + "schema_version": "cento.walk_autopilot.factory_scale.promotion_source.v1", + "run_id": run_dir.name, + "milestone_id": milestone_id, + "source_execution_id": source_execution_id, + "sequence": sequence, + "safe_integrator_handoff": str(item.get("safe_integrator_handoff") or ""), + }, + } + + +def factory_scale_select_promotion_candidates( + run_dir: Path, + promotion_plan: dict[str, Any], + *, + limit: int = 0, + exclusive_paths: bool = False, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + selected: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + seen_paths: set[str] = set() + for sequence, item in enumerate(as_list(promotion_plan.get("plans")), start=1): + if not isinstance(item, dict): + skipped.append({"sequence": sequence, "reason": "plan item is not an object"}) + continue + candidate = factory_scale_normalize_promotion_candidate(run_dir, item, sequence) + touched_paths = set(candidate.get("touched_paths") or []) + if exclusive_paths and seen_paths.intersection(touched_paths): + skipped.append( + { + "sequence": sequence, + "candidate_id": candidate.get("id"), + "reason": "overlaps previously selected touched path", + "overlap": sorted(seen_paths.intersection(touched_paths)), + } + ) + continue + selected.append(candidate) + seen_paths.update(touched_paths) + if limit and len(selected) >= limit: + break + return selected, skipped + + +def factory_scale_promote_to_factory( + run_dir: Path, + *, + promotion_plan_path: Path | None = None, + factory_run: str = "", + apply: bool = False, + validate_each: bool = False, + branch: str = "", + worktree: str = "", + limit: int = 0, + exclusive_paths: bool = True, +) -> dict[str, Any]: + import parallel_delivery + + plan_path = promotion_plan_path or run_dir / FACTORY_SCALE_ADVANCE_DIRNAME / "safe-integrator-promotion-plan.json" + promotion_plan = read_json(plan_path) + if not promotion_plan: + return { + "schema_version": "cento.walk_autopilot.factory_scale.factory_promotion.v1", + "status": "blocked", + "run_id": run_dir.name, + "reason": "promotion plan not found", + "promotion_plan": rel(plan_path), + } + selected, skipped = factory_scale_select_promotion_candidates( + run_dir, + promotion_plan, + limit=limit, + exclusive_paths=exclusive_paths, + ) + if not selected: + return { + "schema_version": "cento.walk_autopilot.factory_scale.factory_promotion.v1", + "status": "blocked", + "run_id": run_dir.name, + "reason": "no promotion candidates selected", + "promotion_plan": rel(plan_path), + "skipped": skipped, + } + factory_run_value = factory_run or f"workspace/runs/factory/factory-scale-promotion-{run_dir.name}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" + promotion = parallel_delivery.promote_patch_swarm_to_factory( + run_dir, + selected, + factory_run=factory_run_value, + apply=apply, + validate_each=validate_each, + branch=branch, + worktree=worktree, + limit=limit, + ) + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.factory_promotion.v1", + "status": promotion.get("status", "unknown"), + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "promotion_plan": rel(plan_path), + "factory_promotion": promotion, + "factory_run_dir": promotion.get("factory_run_dir", ""), + "selected_count": len(selected), + "skipped_count": len(skipped), + "exclusive_paths": bool(exclusive_paths), + "apply": bool(apply), + "selected_candidate_ids": [str(item.get("id") or "") for item in selected], + "skipped": skipped[:50], + "written_at": now_iso(), + } + promotion_receipt = run_dir / FACTORY_SCALE_ADVANCE_DIRNAME / f"factory-promotion-{factory_scale_safe_id(str(Path(factory_run_value).name))}.json" + write_json(promotion_receipt, payload) + factory_scale_append_event( + run_dir, + "factory_promotion_completed", + { + "status": payload.get("status"), + "factory_run_dir": payload.get("factory_run_dir"), + "selected_count": len(selected), + "skipped_count": len(skipped), + "apply": bool(apply), + }, + ) + factory_scale_append_metrics( + run_dir, + { + "tick_result": "factory_promotion_completed", + "factory_promotion_status": payload.get("status"), + "factory_promotion_selected_count": len(selected), + "factory_promotion_skipped_count": len(skipped), + "factory_promotion_apply": bool(apply), + }, + ) + payload["receipt"] = rel(promotion_receipt) + return payload + + +def write_factory_scale_advance_markdown( + report_path: Path, + *, + run_dir: Path, + preflight: dict[str, Any], + live_api_guard: dict[str, Any], + matrix: dict[str, Any], + promotion_plan: dict[str, Any], +) -> None: + lines = [ + "# Factory Scale Advance Report", + "", + f"- Source run: `{run_dir.name}`", + f"- Source status: `{as_dict(matrix.get('source_status')).get('status', 'unknown')}`", + f"- No-overlap decision: `{preflight.get('decision')}`", + f"- Active overlap detected: `{bool(preflight.get('active'))}`", + f"- Live OpenAI/API enabled: `{bool(live_api_guard.get('live_api_enabled'))}`", + f"- Candidate receipts indexed: `{matrix.get('candidate_count')}`", + f"- Selected candidates: `{matrix.get('selected_count')}`", + f"- Promotion plan count: `{promotion_plan.get('promotion_plan_count')}`", + f"- Estimated provider cost from fixture receipts: `${float(matrix.get('estimated_provider_cost_usd') or 0.0):.6f}`", + "", + "## Artifacts", + "", + f"- Candidate matrix: `{rel(report_path.parent / 'candidate-matrix.json')}`", + f"- Promotion plan: `{rel(report_path.parent / 'safe-integrator-promotion-plan.json')}`", + f"- Live API guard: `{rel(report_path.parent / 'live-api-guard.json')}`", + f"- No-overlap preflight: `{rel(report_path.parent / 'no-overlap-preflight.json')}`", + "", + "## Provider Counts", + "", + markdown_list([f"{provider}: {count}" for provider, count in as_dict(matrix.get("provider_counts")).items()]), + "", + "## Selected Provider Counts", + "", + markdown_list([f"{provider}: {count}" for provider, count in as_dict(matrix.get("selected_provider_counts")).items()]), + "", + "## Validation Taxonomy", + "", + "```json", + json.dumps(matrix.get("validation_taxonomy", {}), indent=2, sort_keys=True), + "```", + "", + "## Promotion Gate", + "", + "Selected candidates remain receipts until Factory/Safe Integrator worktrees validate and apply them. This report does not mutate the main worktree.", + "", + "## Next Commands", + "", + f"`./scripts/cento.sh walk-autopilot factory-scale advance --run-id {run_dir.name} --json`", + "", + "`make check`", + "", + ] + report_path.write_text("\n".join(lines), encoding="utf-8") + + +def factory_scale_write_advance_artifacts( + run_dir: Path, + *, + preflight: dict[str, Any], + live_api_guard: dict[str, Any], + promotion_limit: int, +) -> dict[str, Any]: + advance_dir = run_dir / FACTORY_SCALE_ADVANCE_DIRNAME + advance_dir.mkdir(parents=True, exist_ok=True) + matrix = factory_scale_candidate_matrix(run_dir, promotion_limit=promotion_limit) + promotion_plan = factory_scale_promotion_plan(matrix, live_api_guard) + preflight_path = advance_dir / "no-overlap-preflight.json" + live_guard_path = advance_dir / "live-api-guard.json" + matrix_path = advance_dir / "candidate-matrix.json" + promotion_path = advance_dir / "safe-integrator-promotion-plan.json" + report_path = advance_dir / "morning-report.md" + write_json(preflight_path, preflight) + write_json(live_guard_path, live_api_guard) + write_json(matrix_path, matrix) + write_json(promotion_path, promotion_plan) + write_factory_scale_advance_markdown( + report_path, + run_dir=run_dir, + preflight=preflight, + live_api_guard=live_api_guard, + matrix=matrix, + promotion_plan=promotion_plan, + ) + spend_ledger.append_record( + run_dir / "spend-ledger.jsonl", + spend_ledger.build_factory_record( + run_id=run_dir.name, + status="completed", + cost_usd=0.0, + artifact=rel(report_path), + note="Factory scale advance indexed completed Patch Swarm receipts and wrote Safe Integrator promotion plans without live API spend.", + ), + dedupe=False, + ) + factory_scale_append_event( + run_dir, + "advance_completed", + { + "advance_dir": rel(advance_dir), + "candidate_count": matrix.get("candidate_count"), + "selected_count": matrix.get("selected_count"), + "promotion_plan_count": promotion_plan.get("promotion_plan_count"), + "live_api_enabled": bool(live_api_guard.get("live_api_enabled")), + }, + ) + factory_scale_append_metrics( + run_dir, + { + "tick_result": "advance_completed", + "advance_candidate_count": matrix.get("candidate_count"), + "advance_selected_count": matrix.get("selected_count"), + "advance_promotion_plan_count": promotion_plan.get("promotion_plan_count"), + "live_api_enabled": bool(live_api_guard.get("live_api_enabled")), + }, + ) + return { + "schema_version": "cento.walk_autopilot.factory_scale.advance.v1", + "status": "completed", + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "advance_dir": rel(advance_dir), + "candidate_matrix": rel(matrix_path), + "promotion_plan": rel(promotion_path), + "morning_report": rel(report_path), + "no_overlap_preflight": rel(preflight_path), + "live_api_guard": rel(live_guard_path), + "candidate_count": matrix.get("candidate_count"), + "selected_count": matrix.get("selected_count"), + "promotion_plan_count": promotion_plan.get("promotion_plan_count"), + "live_api_enabled": bool(live_api_guard.get("live_api_enabled")), + "live_api_blocked_reasons": live_api_guard.get("blocked_reasons", []), + } + + +def factory_scale_cron_block(run_id: str, duration_hours: float | None = None) -> str: + run_dir = factory_scale_run_dir(run_id) + config = read_json(run_dir / "config.json") + deadline = parse_iso_datetime(config.get("deadline_at")) + if deadline is None: + hours = float(duration_hours if duration_hours is not None else config.get("duration_hours") or 6.0) + deadline = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(hours=hours) + deadline_epoch = int(deadline.timestamp()) + schedule = str(config.get("tick_schedule") or "*/12 * * * *") + batch_size = max(1, int(config.get("batch_size") or 1)) + lock = STATE_DIR / str(config.get("lock_name") or "factory-scale-final-test.lock") + tick_args = f"--run-id {shlex.quote(run_id)} --batch-size {batch_size} --json" + inner = ( + f"if [ \"$(date -u +%s)\" -le {deadline_epoch} ]; then " + f"cd {shlex.quote(str(ROOT))} && ./scripts/cento.sh walk-autopilot factory-scale tick {tick_args}; " + f"else echo 'factory-scale deadline reached for {run_id}'; fi" + ) + conflict = ( + f"cd {shlex.quote(str(ROOT))} && ./scripts/cento.sh walk-autopilot factory-scale tick " + f"--run-id {shlex.quote(run_id)} --cron-lock-conflict --json" + ) + command = ( + f"mkdir -p {shlex.quote(str(STATE_DIR))} {shlex.quote(str(FACTORY_SCALE_LOG_PATH.parent))} " + f"&& flock -n {shlex.quote(str(lock))} bash -lc {shlex.quote(inner)} " + f"|| bash -lc {shlex.quote(conflict)}" + ).replace("%", r"\%") + return "\n".join([FACTORY_SCALE_CRON_BEGIN, f"{schedule} {command} >> {shlex.quote(str(FACTORY_SCALE_LOG_PATH))} 2>&1", FACTORY_SCALE_CRON_END, ""]) + + +def factory_scale_cron_status(crontab_file: str = "") -> dict[str, Any]: + text = read_crontab(crontab_file) + installed = FACTORY_SCALE_CRON_BEGIN in text and FACTORY_SCALE_CRON_END in text + schedule = "" + run_id = "" + if installed: + block = text.split(FACTORY_SCALE_CRON_BEGIN, 1)[1].split(FACTORY_SCALE_CRON_END, 1)[0] + for line in block.splitlines(): + line = line.strip() + if not line: + continue + schedule = " ".join(line.split()[:5]) + if "--run-id" in line: + parts = line.split() + for index, part in enumerate(parts): + if part == "--run-id" and index + 1 < len(parts): + run_id = parts[index + 1].strip("'\"") + break + break + return { + "installed": installed, + "marker_begin_count": text.count(FACTORY_SCALE_CRON_BEGIN), + "marker_end_count": text.count(FACTORY_SCALE_CRON_END), + "schedule": schedule, + "run_id": run_id, + "log_path": rel(FACTORY_SCALE_LOG_PATH), + "crontab_file": crontab_file, + } + + +def factory_scale_process_rows(process_lines: list[str] | None = None) -> list[dict[str, Any]]: + if process_lines is None: + try: + result = subprocess.run(["ps", "-eo", "pid=,etime=,cmd="], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=10) + except (OSError, subprocess.TimeoutExpired): + return [] + if result.returncode != 0: + return [] + process_lines = result.stdout.splitlines() + rows: list[dict[str, Any]] = [] + current_pid = os.getpid() + for line in process_lines: + parts = line.strip().split(None, 2) + if len(parts) < 3: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + if pid == current_pid: + continue + cmd = parts[2] + lower = cmd.lower() + if " rg " in f" {lower} " or "ps -eo" in lower: + continue + if "factory-scale status" in lower or "factory-scale preflight" in lower or "factory-scale advance" in lower or "factory-scale start" in lower: + continue + patterns = ( + "walk-autopilot factory-scale", + "walk_autopilot.py factory-scale", + "parallel-delivery patch-swarm", + "parallel_delivery.py patch-swarm", + "proreq-light", + "proreq_light.py", + ) + if any(pattern in lower for pattern in patterns): + rows.append({"pid": pid, "etime": parts[1], "command": cmd}) + return rows + + +def factory_scale_status_is_active(payload: dict[str, Any]) -> bool: + status = str(payload.get("status") or "") + pending = int(payload.get("pending_proreq_executions") or 0) + cron = as_dict(payload.get("cron")) + if bool(cron.get("installed")): + return True + return status in FACTORY_SCALE_ACTIVE_STATUSES and pending > 0 + + +def factory_scale_no_overlap_preflight( + run_id: str = "", + crontab_file: str = "", + *, + process_lines: list[str] | None = None, +) -> dict[str, Any]: + target = factory_scale_status_payload(run_id, crontab_file) + latest = target if not run_id else factory_scale_status_payload("", crontab_file) + cron = factory_scale_cron_status(crontab_file) + processes = factory_scale_process_rows(process_lines) + target_active = factory_scale_status_is_active(target) + latest_active = factory_scale_status_is_active(latest) and str(latest.get("run_id") or "") != str(target.get("run_id") or "") + cron_active = bool(cron.get("installed")) + process_active = bool(processes) + active = bool(target_active or latest_active or cron_active or process_active) + if active: + decision = "attach_existing" + elif str(target.get("status") or "") == "completed": + decision = "reuse_completed_run" + elif str(target.get("status") or "") == "unknown": + decision = "safe_to_start" + else: + decision = "safe_to_advance" + return { + "schema_version": "cento.walk_autopilot.factory_scale.no_overlap_preflight.v1", + "checked_at": now_iso(), + "run_id": str(target.get("run_id") or run_id), + "active": active, + "decision": decision, + "target_status": target, + "latest_status": latest, + "cron": cron, + "active_processes": processes, + "process_active": process_active, + "cron_active": cron_active, + "target_active": target_active, + "latest_active": latest_active, + } + + +def parse_factory_scale_live_api_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + live_categories = {"api", "pro", "image"} + rows: list[dict[str, Any]] = [] + for record in records: + if record.get("duplicate_of"): + continue + if str(record.get("provider") or "") != "openai": + continue + if str(record.get("category") or "") not in live_categories: + continue + if str(record.get("status") or "") not in {"started", "completed", "failed", "timeout"}: + continue + written = parse_iso_datetime(record.get("written_at")) + if written is None: + continue + rows.append({"written_at": written, "record": record}) + rows.sort(key=lambda item: item["written_at"]) + return rows + + +def factory_scale_live_api_rate_limit( + records: list[dict[str, Any]], + *, + max_calls_per_hour: int = 4, + min_spacing_seconds: int = 900, + checked_at: datetime | None = None, +) -> dict[str, Any]: + checked_at = checked_at or datetime.now(timezone.utc).replace(microsecond=0) + live_rows = parse_factory_scale_live_api_records(records) + last = live_rows[-1] if live_rows else None + window_started = checked_at - timedelta(hours=1) + recent = [item for item in live_rows if item["written_at"] >= window_started] + seconds_since_last: int | None = None + last_call_at = "" + if last: + seconds_since_last = max(0, int((checked_at - last["written_at"]).total_seconds())) + last_call_at = last["written_at"].isoformat().replace("+00:00", "Z") + blocked_reasons: list[str] = [] + if max_calls_per_hour <= 0: + blocked_reasons.append("max live OpenAI calls per hour is zero") + elif len(recent) >= max_calls_per_hour: + blocked_reasons.append(f"live OpenAI call count in the last hour is {len(recent)} >= {max_calls_per_hour}") + if seconds_since_last is not None and seconds_since_last < min_spacing_seconds: + blocked_reasons.append(f"last live OpenAI call was {seconds_since_last}s ago < {min_spacing_seconds}s minimum spacing") + return { + "schema_version": "cento.walk_autopilot.factory_scale.live_api_rate_limit.v1", + "checked_at": checked_at.isoformat().replace("+00:00", "Z"), + "allowed": not blocked_reasons, + "blocked_reasons": blocked_reasons, + "max_live_calls_per_hour": max_calls_per_hour, + "min_live_call_spacing_seconds": min_spacing_seconds, + "recent_live_call_count": len(recent), + "live_call_record_count": len(live_rows), + "last_live_call_at": last_call_at, + "seconds_since_last_live_call": seconds_since_last, + } + + +def factory_scale_live_api_guard(args: argparse.Namespace, run_dir: Path) -> dict[str, Any]: + records = spend_ledger.read_jsonl(run_dir / "spend-ledger.jsonl") + summary = spend_ledger.summarize_records(records) + budget_gate = live_api_budget_gate(args, summary) + rate_limit = factory_scale_live_api_rate_limit( + records, + max_calls_per_hour=int(getattr(args, "max_live_calls_per_hour", 4)), + min_spacing_seconds=int(getattr(args, "min_live_call_spacing_seconds", 900)), + ) + requested = bool(getattr(args, "allow_live_api", False)) + enabled = bool(requested and budget_gate.get("allowed") and rate_limit.get("allowed")) + blocked_reasons: list[str] = [] + if requested and not bool(budget_gate.get("allowed")): + blocked_reasons.append(str(budget_gate.get("reason") or "live OpenAI budget gate blocked")) + if requested and not bool(rate_limit.get("allowed")): + blocked_reasons.extend([str(item) for item in as_list(rate_limit.get("blocked_reasons"))]) + if not requested: + blocked_reasons.append("live OpenAI/API lane not requested") + return { + "schema_version": "cento.walk_autopilot.factory_scale.live_api_guard.v1", + "checked_at": now_iso(), + "run_id": run_dir.name, + "live_api_requested": requested, + "live_api_enabled": enabled, + "fail_closed": not enabled, + "blocked_reasons": blocked_reasons, + "budget_gate": budget_gate, + "rate_limit": rate_limit, + "lock_path": str(STATE_DIR / LIVE_API_LOCK_NAME), + "policy": { + "default_overnight_target_usd": 10.0, + "default_overnight_hard_cap_usd": float(getattr(args, "hard_cap_usd", 25.0)), + "no_cron_live_api_without_lock_budget_and_rate_limit": True, + "dashboard_total_required_for_live_api": True, + }, + } + + +def run_json_command(command: list[str], *, timeout: int) -> tuple[dict[str, Any], dict[str, Any]]: + started = time.monotonic() + try: + result = subprocess.run( + command, + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + stdout = result.stdout or "" + stderr = result.stderr or "" + meta: dict[str, Any] = { + "command": command, + "command_text": shlex.join(command), + "exit_code": result.returncode, + "duration_seconds": round(time.monotonic() - started, 3), + "timed_out": False, + "stdout_bytes": len(stdout.encode("utf-8", errors="ignore")), + "stderr_bytes": len(stderr.encode("utf-8", errors="ignore")), + } + if stderr: + meta["stderr_sha256"] = sha256_text(stderr) + if stdout.strip(): + try: + payload = json.loads(stdout) + meta["json_ok"] = isinstance(payload, dict) + except json.JSONDecodeError: + payload = {} + meta["json_ok"] = False + meta["stdout_sha256"] = sha256_text(stdout) + else: + payload = {} + meta["json_ok"] = result.returncode == 0 + return (payload if isinstance(payload, dict) else {}, meta) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout if isinstance(exc.stdout, str) else "" + stderr = exc.stderr if isinstance(exc.stderr, str) else "" + meta = { + "command": command, + "command_text": shlex.join(command), + "exit_code": 124, + "duration_seconds": round(time.monotonic() - started, 3), + "timed_out": True, + "stdout_bytes": len(stdout.encode("utf-8", errors="ignore")), + "stderr_bytes": len(stderr.encode("utf-8", errors="ignore")), + "json_ok": False, + } + if stderr: + meta["stderr_sha256"] = sha256_text(stderr) + if stdout: + meta["stdout_sha256"] = sha256_text(stdout) + return {}, meta + + +def as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def int_or_none(value: Any) -> int | None: + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def issue_status_key(issue: dict[str, Any]) -> str: + return str(issue.get("status") or "").strip().lower() + + +def issue_by_id(issues_payload: dict[str, Any]) -> dict[int, dict[str, Any]]: + items: dict[int, dict[str, Any]] = {} + for item in as_list(issues_payload.get("issues")): + if not isinstance(item, dict): + continue + issue_id = int_or_none(item.get("id")) + if issue_id: + items[issue_id] = item + return items + + +def active_issue_ids_from_runs(runs_payload: dict[str, Any]) -> set[int]: + active: set[int] = set() + for item in as_list(runs_payload.get("runs")): + if not isinstance(item, dict): + continue + issue_id = int_or_none(item.get("issue_id")) + if not issue_id: + continue + status = str(item.get("status") or "").strip().lower() + pid_alive = bool(item.get("pid_alive")) or bool(item.get("pid")) + tmux_alive = bool(item.get("tmux_alive")) + if status in REVIEW_UNBLOCK_ACTIVE_RUN_STATUSES and (pid_alive or tmux_alive): + active.add(issue_id) + return active + + +def canonical_agent_work_dir(issue_id: int) -> Path: + return ROOT / "workspace" / "runs" / "agent-work" / str(issue_id) + + +def canonical_story_path(issue_id: int) -> Path: + return canonical_agent_work_dir(issue_id) / "story.json" + + +def canonical_validation_path(issue_id: int) -> Path: + return canonical_agent_work_dir(issue_id) / "validation.json" + + +def validation_run_note(issue_id: int, validation_path: Path) -> str: + return "\n\n".join( + [ + "## Delivered\n- Review/Unblock Autopilot ran the deterministic local validator path.", + f"## Validation\n- Executed `agent-work validate-run {issue_id}` using `{rel(validation_path)}`.", + "## Evidence\n- `validate-run` writes validation-report markdown, JSON, and review summary artifacts in the canonical issue run directory.", + "## Residual risk\n- Automated checks may miss subjective review ambiguity; escalate if the generated report fails the strict review gate.", + ] + ) + + +def review_unblock_mode_for_args(args: argparse.Namespace) -> str: + if bool(getattr(args, "no_review_unblock", False)): + return "off" + explicit = str(getattr(args, "review_unblock_mode", "") or "").strip().lower() + if explicit: + return explicit + return "aggressive" if bool(getattr(args, "live_workers", False)) else "report" + + +def review_unblock_action( + action_type: str, + *, + issue_id: int | None = None, + package: str = "", + reason: str = "", + command: list[str] | None = None, + apply: bool = False, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + seed = json.dumps( + {"type": action_type, "issue_id": issue_id, "package": package, "reason": reason, "command": command or []}, + sort_keys=True, + ) + payload: dict[str, Any] = { + "id": f"{action_type}-{hashlib.sha256(seed.encode('utf-8')).hexdigest()[:10]}", + "type": action_type, + "issue_id": issue_id, + "package": package, + "reason": reason, + "command": command or [], + "apply": bool(apply), + "status": "planned", + } + if extra: + payload.update(extra) + return payload + + +def collect_review_unblock_snapshot(stage_dir: Path) -> dict[str, Any]: + snapshot_dir = stage_dir / "snapshot" + snapshot_dir.mkdir(parents=True, exist_ok=True) + recovery_payload, recovery_meta = run_json_command( + [ + "./scripts/cento.sh", + "agent-work", + "recovery-plan", + "--json", + "--run-dir", + rel(stage_dir / "recovery-plan"), + ], + timeout=180, + ) + issues_payload, issues_meta = run_json_command(["./scripts/cento.sh", "agent-work", "list", "--all", "--json"], timeout=180) + runs_payload, runs_meta = run_json_command(["./scripts/cento.sh", "agent-work", "runs", "--json"], timeout=180) + + write_json(snapshot_dir / "recovery-plan.json", recovery_payload) + write_json(snapshot_dir / "agent-work-list.json", issues_payload) + write_json(snapshot_dir / "agent-work-runs.json", runs_payload) + command_meta = {"recovery_plan": recovery_meta, "agent_work_list": issues_meta, "agent_work_runs": runs_meta} + write_json(snapshot_dir / "commands.json", command_meta) + + return { + "schema_version": "cento.review_unblock.snapshot.v1", + "collected_at": now_iso(), + "stage_dir": rel(stage_dir), + "recovery": recovery_payload, + "issues": issues_payload, + "runs": runs_payload, + "commands": command_meta, + } + + +def repair_story_payload(candidate: dict[str, Any], stage_dir: Path) -> dict[str, Any]: + source_issue = int_or_none(candidate.get("source_issue_id")) or 0 + package = str(candidate.get("package") or "agent-ops") + title = str(candidate.get("title") or f"Repair blocked Agent Work issue {source_issue}").strip() + description = " ".join(str(candidate.get("description") or "").split()) + if len(description) > 900: + description = description[:897].rstrip() + "..." + repair_run_dir = f"workspace/runs/agent-work/review-unblock/{source_issue or 'unknown'}" + return { + "schema_version": "1.0", + "issue": {"id": 0, "title": title, "package": package}, + "lane": {"owner": "walk-autopilot", "node": "linux", "agent": "", "role": "builder"}, + "paths": {"run_dir": repair_run_dir}, + "scope": { + "goal": ( + f"Resolve the bounded recovery blocker for Agent Work issue #{source_issue}. " + f"Recovery reason: {candidate.get('reason') or 'follow-up candidate'}." + ), + "acceptance": [ + "Inspect the source issue, recovery-plan note, canonical story/validation artifacts, and run evidence before changing status.", + "Either repair the missing artifact/evidence, requeue with a precise note, or leave a closure recommendation with evidence.", + "Do not broaden ownership beyond the source issue's run artifacts unless the issue text explicitly requires it.", + ], + }, + "expected_outputs": [ + { + "path": f"{repair_run_dir}/worker-handoff.md", + "description": "Concise handoff describing the blocker, repair performed, validation, evidence, and residual risk.", + "owner": "builder", + "required": True, + }, + { + "path": f"{repair_run_dir}/review-unblock-report.json", + "description": "Machine-readable summary of the reviewed blocker and recommended next state.", + "owner": "builder", + "required": True, + }, + ], + "validation": { + "manifest": f"{repair_run_dir}/validation.json", + "mode": "no-model", + "no_model_eligible": True, + "risk": "medium", + "escalation_triggers": ["missing_manifest", "failed_deterministic_command", "ambiguity"], + "commands": [ + f"./scripts/cento.sh agent-work show {source_issue} --json", + "python3 -m py_compile scripts/walk_autopilot.py", + ], + }, + "deliverables": { + "manifest": f"{repair_run_dir}/deliverables.json", + "hub": f"{repair_run_dir}/start-here.html", + }, + "review_gate": { + "required_sections": ["Delivered", "Validation", "Evidence", "Residual risk"], + "residual_risk_required": True, + }, + "metadata": { + "drafted_at": now_iso(), + "source": "walk-autopilot-review-unblock", + "source_issue_id": source_issue, + "recovery_reason": candidate.get("reason") or "", + "description_excerpt": description, + "stage_dir": rel(stage_dir), + }, + } + + +def choose_review_unblock_candidates(snapshot: dict[str, Any], *, apply_allowed: bool, dirty_blocked: bool) -> list[dict[str, Any]]: + recovery = as_dict(snapshot.get("recovery")) + review = as_dict(recovery.get("review")) + runs = as_dict(recovery.get("runs")) + issues_payload = as_dict(snapshot.get("issues")) + runs_payload = as_dict(snapshot.get("runs")) + issues = issue_by_id(issues_payload) + active_issue_ids = active_issue_ids_from_runs(runs_payload) + actions: list[dict[str, Any]] = [] + seen_keys: set[tuple[str, int | str]] = set() + + def add(action: dict[str, Any], key: tuple[str, int | str] | None = None) -> None: + action_key = key or (str(action.get("type") or ""), int_or_none(action.get("issue_id")) or str(action.get("package") or action.get("id") or "")) + if action_key in seen_keys: + return + seen_keys.add(action_key) + if dirty_blocked and str(action.get("type") or "") in REVIEW_UNBLOCK_MUTATING_TYPES: + action["apply"] = False + action["blocked_reason"] = "git dirty count changed during Review/Unblock snapshot collection" + elif str(action.get("type") or "") in REVIEW_UNBLOCK_MUTATING_TYPES: + action["apply"] = bool(apply_allowed) + actions.append(action) + + close_budget = REVIEW_UNBLOCK_ACTION_CAPS["close_done"] + for package_item in as_list(review.get("packages_ready")): + if not isinstance(package_item, dict): + continue + package = str(package_item.get("package") or "").strip() + count = int_or_none(package_item.get("count")) or len(as_list(package_item.get("issue_ids"))) + if not package: + continue + if count <= 0: + continue + if count > close_budget: + add( + review_unblock_action( + "operator_needed", + package=package, + reason=f"Review package `{package}` has {count} ready issue(s), exceeding remaining close cap {close_budget}.", + apply=False, + extra={"issue_ids": as_list(package_item.get("issue_ids"))}, + ), + ("operator_needed", f"close_cap_{package}"), + ) + continue + close_budget -= count + add( + review_unblock_action( + "close_done", + package=package, + reason=f"{count} Review issue(s) have passing validation and evidence.", + command=[ + "./scripts/cento.sh", + "agent-work", + "review-drain", + "--package", + package, + "--run-dir", + "__STAGE_ACTION_DIR__", + "--json", + "--apply", + ], + apply=apply_allowed, + extra={"issue_ids": as_list(package_item.get("issue_ids"))}, + ), + ("close_done", package), + ) + + local_validations = 0 + dispatches = 0 + for issue in as_list(issues_payload.get("issues")): + if not isinstance(issue, dict): + continue + issue_id = int_or_none(issue.get("id")) + if not issue_id or issue_id in active_issue_ids: + continue + status = issue_status_key(issue) + if status != "validating": + continue + story_path = canonical_story_path(issue_id) + validation_path = canonical_validation_path(issue_id) + if story_path.exists() and validation_path.exists() and local_validations < REVIEW_UNBLOCK_ACTION_CAPS["validate_local"]: + local_validations += 1 + add( + review_unblock_action( + "validate_local", + issue_id=issue_id, + package=str(issue.get("package") or ""), + reason="Validating issue has canonical story and validation manifests and no active run.", + command=[ + "./scripts/cento.sh", + "agent-work", + "validate-run", + str(issue_id), + "--manifest", + rel(validation_path), + "--story-manifest", + rel(story_path), + "--note", + validation_run_note(issue_id, validation_path), + "--json", + ], + apply=apply_allowed, + ), + ("validate_local", issue_id), + ) + elif story_path.exists() and validation_path.exists() and dispatches < REVIEW_UNBLOCK_ACTION_CAPS["dispatch_validator"]: + dispatches += 1 + add( + review_unblock_action( + "dispatch_validator", + issue_id=issue_id, + package=str(issue.get("package") or ""), + reason="Validating issue has manifests but exceeded the local validation cap; launch a bounded validator.", + command=[ + "./scripts/cento.sh", + "agent-work", + "dispatch", + str(issue_id), + "--role", + "validator", + "--runtime", + "auto", + "--validation-manifest", + rel(validation_path), + ], + apply=apply_allowed, + ), + ("dispatch_validator", issue_id), + ) + else: + add( + review_unblock_action( + "operator_needed", + issue_id=issue_id, + package=str(issue.get("package") or ""), + reason="Validating issue has no active run but is missing a canonical story or validation manifest.", + apply=False, + extra={"story_manifest": rel(story_path), "validation_manifest": rel(validation_path)}, + ), + ("operator_needed", issue_id), + ) + + requeue_count = 0 + for item in as_list(recovery.get("blocked_requeue")): + if requeue_count >= REVIEW_UNBLOCK_ACTION_CAPS["requeue_stale_dispatch"]: + break + if not isinstance(item, dict): + continue + issue_id = int_or_none(item.get("id")) + if not issue_id or issue_id in active_issue_ids: + continue + requeue_count += 1 + role = str(item.get("role") or "builder") + add( + review_unblock_action( + "requeue_stale_dispatch", + issue_id=issue_id, + package=str(item.get("package") or ""), + reason=str(item.get("reason") or "stale dispatch can be requeued"), + command=[ + "./scripts/cento.sh", + "agent-work", + "update", + str(issue_id), + "--status", + "queued", + "--role", + role, + "--note", + "Review/Unblock Autopilot requeued this issue because the prior dispatch is stale and no active run is attached.", + "--json", + ], + apply=apply_allowed, + ), + ("requeue_stale_dispatch", issue_id), + ) + + for item in as_list(runs.get("stale_items")): + if not isinstance(item, dict): + continue + issue_id = int_or_none(item.get("issue_id")) + run_id = str(item.get("run_id") or "").strip() + if not issue_id or not run_id: + continue + issue = issues.get(issue_id, {}) + status = issue_status_key(issue) + if status in {"done", "closed"} or bool(issue.get("is_closed")): + if len([action for action in actions if action.get("type") == "archive_stale_historical"]) >= REVIEW_UNBLOCK_ACTION_CAPS["archive_stale_historical"]: + continue + add( + review_unblock_action( + "archive_stale_historical", + issue_id=issue_id, + package=str(issue.get("package") or item.get("package") or ""), + reason="Stale run ledger belongs to a Done or closed issue.", + command=["python3", "scripts/agent_manager.py", "reconcile-ledger", run_id, "--apply"], + apply=apply_allowed, + extra={"run_id": run_id}, + ), + ("archive_stale_historical", run_id), + ) + elif status in {"running", "validating", "blocked"} and issue_id not in active_issue_ids: + if requeue_count >= REVIEW_UNBLOCK_ACTION_CAPS["requeue_stale_dispatch"]: + continue + requeue_count += 1 + role = str(issue.get("role") or item.get("role") or "builder") + add( + review_unblock_action( + "requeue_stale_dispatch", + issue_id=issue_id, + package=str(issue.get("package") or item.get("package") or ""), + reason=f"Stale {item.get('role') or 'worker'} ledger has no live pid or tmux session.", + command=[ + "./scripts/cento.sh", + "agent-work", + "update", + str(issue_id), + "--status", + "queued", + "--role", + role, + "--note", + f"Review/Unblock Autopilot requeued this issue because run `{run_id}` is stale and no active process is attached.", + "--json", + ], + apply=apply_allowed, + extra={"run_id": run_id}, + ), + ("requeue_stale_dispatch", issue_id), + ) + + for item in as_list(recovery.get("follow_up_candidates"))[: REVIEW_UNBLOCK_ACTION_CAPS["repair_task"]]: + if not isinstance(item, dict): + continue + source_issue_id = int_or_none(item.get("source_issue_id")) + if not source_issue_id: + continue + story_manifest = f"__STAGE_DRAFT_DIR__/repair-{source_issue_id}.story.json" + add( + review_unblock_action( + "repair_task", + issue_id=source_issue_id, + package=str(item.get("package") or "agent-ops"), + reason=str(item.get("reason") or "bounded follow-up candidate"), + command=[ + "./scripts/cento.sh", + "agent-work", + "create", + "--title", + str(item.get("title") or f"Repair blocked Agent Work issue {source_issue_id}"), + "--description", + str(item.get("description") or f"Repair blocked Agent Work issue {source_issue_id}"), + "--node", + "linux", + "--role", + "builder", + "--package", + str(item.get("package") or "agent-ops"), + "--manifest", + story_manifest, + "--owns", + f"workspace/runs/agent-work/{source_issue_id}/", + "--json", + ], + apply=apply_allowed, + extra={"source_candidate": item, "story_manifest": story_manifest}, + ), + ("repair_task", source_issue_id), + ) + + demo_closed = 0 + for issue in as_list(issues_payload.get("issues")): + if not isinstance(issue, dict) or not bool(issue.get("test_artifact")): + continue + issue_id = int_or_none(issue.get("id")) + if not issue_id or issue_id in active_issue_ids: + continue + status = issue_status_key(issue) + if status in {"done", "closed", "running", "validating", "review"}: + continue + if demo_closed >= REVIEW_UNBLOCK_ACTION_CAPS["close_demo_test"]: + continue + demo_closed += 1 + role = str(issue.get("role") or "coordinator") + add( + review_unblock_action( + "close_demo_test", + issue_id=issue_id, + package=str(issue.get("package") or ""), + reason="Issue is tagged by Agent Work as demo/test/stale inventory and has no active run.", + command=[ + "./scripts/cento.sh", + "agent-work", + "update", + str(issue_id), + "--status", + "done", + "--role", + role, + "--note", + "Review/Unblock Autopilot closed this demo/test inventory item; no active run was attached.", + "--json", + ], + apply=apply_allowed, + ), + ("close_demo_test", issue_id), + ) + + if dirty_blocked: + add( + review_unblock_action( + "operator_needed", + reason="Git dirty count changed during snapshot collection, so mutating Review/Unblock actions were blocked.", + apply=False, + ), + ("operator_needed", "dirty_changed"), + ) + + command_failures = [ + name + for name, meta in as_dict(snapshot.get("commands")).items() + if isinstance(meta, dict) and int(meta.get("exit_code") or 0) != 0 + ] + if command_failures: + add( + review_unblock_action( + "operator_needed", + reason=f"Review/Unblock snapshot command(s) failed: {', '.join(command_failures)}.", + apply=False, + extra={"command_failures": command_failures}, + ), + ("operator_needed", "command_failures"), + ) + + return actions + + +def decide_review_unblock(snapshot: dict[str, Any], *, mode: str, dirty_before: int, dirty_after: int) -> dict[str, Any]: + apply_allowed = mode == "aggressive" and dirty_before == dirty_after + dirty_blocked = dirty_before != dirty_after + actions = choose_review_unblock_candidates(snapshot, apply_allowed=apply_allowed, dirty_blocked=dirty_blocked) + type_counts = Counter(str(item.get("type") or "unknown") for item in actions) + applicable = [item for item in actions if bool(item.get("apply"))] + return { + "schema_version": "cento.review_unblock.decision.v1", + "decided_at": now_iso(), + "mode": mode, + "authority": "report" if mode == "report" else "bounded_apply", + "apply_allowed": apply_allowed, + "dirty_count_before": dirty_before, + "dirty_count_after": dirty_after, + "action_caps": REVIEW_UNBLOCK_ACTION_CAPS, + "summary": { + "action_count": len(actions), + "applicable_count": len(applicable), + "operator_needed_count": type_counts.get("operator_needed", 0), + "type_counts": dict(sorted(type_counts.items())), + }, + "actions": actions, + "next_iteration": [ + "Compare action type counts across two loops before raising caps.", + "Keep review-drain closure evidence-first; never close Review without validation pass plus evidence.", + "Convert repeated operator_needed reasons into narrower deterministic repair rules only after two samples.", + ], + } + + +def materialize_review_unblock_action(action: dict[str, Any], action_dir: Path) -> list[str]: + command = [str(item) for item in as_list(action.get("command"))] + replacements = { + "__STAGE_ACTION_DIR__": rel(action_dir), + "__STAGE_DRAFT_DIR__": rel(action_dir / "drafts"), + } + materialized: list[str] = [] + for item in command: + for source, replacement in replacements.items(): + item = item.replace(source, replacement) + materialized.append(item) + if action.get("type") == "repair_task": + source_candidate = as_dict(action.get("source_candidate")) + source_issue = int_or_none(action.get("issue_id")) or 0 + draft_path = action_dir / "drafts" / f"repair-{source_issue}.story.json" + write_json(draft_path, repair_story_payload(source_candidate, action_dir)) + action["story_manifest_materialized"] = rel(draft_path) + return materialized + + +def apply_review_unblock_actions(actions: list[dict[str, Any]], stage_dir: Path) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + for index, original in enumerate(actions, start=1): + action = dict(original) + action_dir = stage_dir / "actions" / f"{index:03d}-{action.get('type')}" + action_dir.mkdir(parents=True, exist_ok=True) + if not bool(action.get("apply")): + action["status"] = "skipped_report" + write_json(action_dir / "action.json", action) + append_jsonl(stage_dir / "actions.jsonl", action) + results.append(action) + continue + command = materialize_review_unblock_action(action, action_dir) + action["command"] = command + timeout = 240 if action.get("type") in {"validate_local", "dispatch_validator"} else 120 + result = run_command(command, timeout=timeout) + action["result"] = result + action["status"] = "applied" if int(result.get("exit_code") or 0) == 0 else "failed" + write_json(action_dir / "action.json", action) + append_jsonl(stage_dir / "actions.jsonl", action) + results.append(action) + return results + + +def write_review_unblock_report(path: Path, snapshot: dict[str, Any], decision: dict[str, Any], results: list[dict[str, Any]]) -> None: + summary = as_dict(decision.get("summary")) + applied = [item for item in results if item.get("status") == "applied"] + failed = [item for item in results if item.get("status") == "failed"] + skipped = [item for item in results if item.get("status") == "skipped_report"] + lines = [ + "# Review/Unblock Autopilot", + "", + f"- Mode: `{decision.get('mode')}`", + f"- Authority: `{decision.get('authority')}`", + f"- Actions: `{summary.get('action_count', 0)}`", + f"- Applicable: `{summary.get('applicable_count', 0)}`", + f"- Applied: `{len(applied)}`", + f"- Failed: `{len(failed)}`", + f"- Skipped/report-only: `{len(skipped)}`", + f"- Operator-needed: `{summary.get('operator_needed_count', 0)}`", + "", + "## Type Counts", + "", + markdown_list([f"`{key}`: {value}" for key, value in as_dict(summary.get("type_counts")).items()]), + "", + "## Actions", + "", + ] + for item in results: + issue = f"#{item.get('issue_id')}" if item.get("issue_id") else "-" + lines.append( + f"- `{item.get('status')}` `{item.get('type')}` issue={issue} package=`{item.get('package') or '-'}` reason={item.get('reason')}" + ) + if not results: + lines.append("- No action candidates.") + command_meta = as_dict(snapshot.get("commands")) + lines.extend( + [ + "", + "## Snapshot Commands", + "", + markdown_list( + [ + f"`{name}` exit={as_dict(meta).get('exit_code')} json_ok={as_dict(meta).get('json_ok')}" + for name, meta in command_meta.items() + ] + ), + "", + "## Next Iteration", + "", + markdown_list([str(item) for item in as_list(decision.get("next_iteration"))]), + "", + ] + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def latest_review_unblock_run_dir() -> Path | None: + if not REVIEW_UNBLOCK_RUN_ROOT.exists(): + return None + runs = [path for path in REVIEW_UNBLOCK_RUN_ROOT.glob("review-unblock-*") if path.is_dir()] + return sorted(runs)[-1] if runs else None + + +def mirror_review_unblock_latest(run_dir: Path) -> None: + REVIEW_UNBLOCK_LATEST_DIR.parent.mkdir(parents=True, exist_ok=True) + if REVIEW_UNBLOCK_LATEST_DIR.exists() or REVIEW_UNBLOCK_LATEST_DIR.is_symlink(): + if REVIEW_UNBLOCK_LATEST_DIR.is_dir() and not REVIEW_UNBLOCK_LATEST_DIR.is_symlink(): + shutil.rmtree(REVIEW_UNBLOCK_LATEST_DIR) + else: + REVIEW_UNBLOCK_LATEST_DIR.unlink() + shutil.copytree(run_dir, REVIEW_UNBLOCK_LATEST_DIR) + + +def run_review_unblock_stage(stage_dir: Path, *, mode: str) -> dict[str, Any]: + stage_dir.mkdir(parents=True, exist_ok=True) + dirty_before = count_dirty_files() + snapshot = collect_review_unblock_snapshot(stage_dir) + dirty_after = count_dirty_files() + decision = decide_review_unblock(snapshot, mode=mode, dirty_before=dirty_before, dirty_after=dirty_after) + write_json(stage_dir / "snapshot.json", snapshot) + write_json(stage_dir / "decision.json", decision) + results = apply_review_unblock_actions(as_list(decision.get("actions")), stage_dir) + write_json(stage_dir / "results.json", {"schema_version": "cento.review_unblock.results.v1", "results": results}) + write_review_unblock_report(stage_dir / "decision_report.md", snapshot, decision, results) + type_counts = Counter(str(item.get("type") or "unknown") for item in results) + status_counts = Counter(str(item.get("status") or "unknown") for item in results) + failed_count = status_counts.get("failed", 0) + return { + "schema_version": "cento.review_unblock.stage.v1", + "status": "completed" if failed_count == 0 else "failed", + "exit_code": 0 if failed_count == 0 else 1, + "mode": mode, + "stage_dir": rel(stage_dir), + "decision_report": rel(stage_dir / "decision_report.md"), + "summary": as_dict(decision.get("summary")), + "type_counts": dict(sorted(type_counts.items())), + "status_counts": dict(sorted(status_counts.items())), + "failed_count": failed_count, + "applied_count": status_counts.get("applied", 0), + "skipped_count": status_counts.get("skipped_report", 0), + "operator_needed_count": type_counts.get("operator_needed", 0), + } + + +def review_unblock_command_record(stage: dict[str, Any]) -> dict[str, Any]: + return { + "name": "review-unblock", + "exit_code": int(stage.get("exit_code") or 0), + "command": ["internal", "review-unblock-stage"], + "command_text": f"internal review-unblock stage mode={stage.get('mode')}", + "stdout_tail": json.dumps({"summary": stage.get("summary"), "decision_report": stage.get("decision_report")}, sort_keys=True), + "stderr_tail": "", + "duration_seconds": 0, + "timed_out": False, + "review_unblock": stage, + } + + +def collect_git_counts() -> dict[str, Any]: + result = run_command(["git", "status", "--short"], timeout=20) + lines = [line for line in str(result.get("stdout_tail") or "").splitlines() if line.strip()] + status_counts = Counter((line[:2] or "??").strip() or line[:2] for line in lines) + return { + "exit_code": result.get("exit_code"), + "dirty_count": len(lines) if int(result.get("exit_code") or 0) == 0 else -1, + "status_counts": dict(sorted(status_counts.items())), + } + + +def collect_tool_registry_counts() -> dict[str, Any]: + payload = read_json(ROOT / "data" / "tools.json") + tools = payload.get("tools") if isinstance(payload.get("tools"), list) else [] + walk = next((item for item in tools if isinstance(item, dict) and item.get("id") == "walk-autopilot"), {}) + commands = walk.get("commands") if isinstance(walk, dict) and isinstance(walk.get("commands"), list) else [] + docs = walk.get("docs") if isinstance(walk, dict) and isinstance(walk.get("docs"), list) else [] + return { + "tool_count": len(tools), + "walk_autopilot_registered": bool(walk), + "walk_autopilot_command_count": len(commands), + "walk_autopilot_routing_command_count": len([item for item in commands if " routing " in str(item)]), + "walk_autopilot_docs_count": len(docs), + } + + +def collect_cli_docs_counts() -> dict[str, Any]: + payload = read_json(ROOT / "data" / "cento-cli.json") + commands = payload.get("commands") if isinstance(payload.get("commands"), list) else [] + routing = payload.get("routing") if isinstance(payload.get("routing"), list) else [] + notes = payload.get("notes") if isinstance(payload.get("notes"), list) else [] + doc_path = ROOT / "docs" / "ai-routing-nativeness-loop.md" + return { + "builtin_command_count": len(commands), + "routing_entry_count": len(routing), + "notes_count": len(notes), + "human_routing_doc_exists": doc_path.exists(), + "human_routing_doc_bytes": doc_path.stat().st_size if doc_path.exists() else 0, + } + + +def summarize_walk_status(payload: dict[str, Any], meta: dict[str, Any]) -> dict[str, Any]: + metrics = payload.get("metrics_records") + loops = payload.get("loops") if isinstance(payload.get("loops"), list) else [] + incidents = payload.get("incidents") if isinstance(payload.get("incidents"), list) else [] + spend = payload.get("spend") if isinstance(payload.get("spend"), dict) else {} + return { + "command": meta, + "run_id": payload.get("run_id") or "", + "metrics_records": metrics if isinstance(metrics, int) else 0, + "loop_count": len(loops), + "incident_count": len(incidents), + "spend_total_usd": spend.get("total_cost_usd"), + } + + +def collect_walk_autopilot_status() -> dict[str, Any]: + payload, meta = run_json_command(["./scripts/cento.sh", "walk-autopilot", "status"], timeout=90) + return summarize_walk_status(payload, meta) + + +def collect_self_improve_status() -> dict[str, Any]: + payload, meta = run_json_command(["./scripts/cento.sh", "parallel-delivery", "self-improve", "status", "--json"], timeout=120) + validation = payload.get("validation") if isinstance(payload.get("validation"), dict) else {} + promotion = payload.get("promotion") if isinstance(payload.get("promotion"), dict) else {} + return { + "command": meta, + "run_dir": payload.get("run_dir") or "", + "latest_dir": payload.get("latest_dir") or "", + "cron_installed": bool(payload.get("cron_installed")), + "status": payload.get("status") or "unknown", + "validation_status": validation.get("status") or payload.get("validation_status") or "unknown", + "promotion_recommendation": promotion.get("recommendation") or payload.get("promotion_recommendation") or "unknown", + } + + +def aggregate_agent_runs(payload: dict[str, Any], meta: dict[str, Any]) -> dict[str, Any]: + runs = payload.get("runs") if isinstance(payload.get("runs"), list) else [] + status_counts: Counter[str] = Counter() + health_counts: Counter[str] = Counter() + role_counts: Counter[str] = Counter() + runtime_counts: Counter[str] = Counter() + demo_test_run_count = 0 + stale_count = 0 + failed_count = 0 + running_count = 0 + for item in runs: + if not isinstance(item, dict): + continue + status = str(item.get("status") or "unknown") + health = str(item.get("health") or "unknown") + role = str(item.get("role") or "unknown") + runtime = str(item.get("runtime") or "unknown") + status_counts[status] += 1 + health_counts[health] += 1 + role_counts[role] += 1 + runtime_counts[runtime] += 1 + if "stale" in status or "stale" in health: + stale_count += 1 + if status == "failed" or health == "failed": + failed_count += 1 + if status == "running" or health == "running": + running_count += 1 + haystack = " ".join( + [ + str(item.get("issue_subject") or ""), + str(item.get("package") or ""), + str(item.get("run_id") or ""), + ] + ).lower() + if any(term in haystack for term in ("demo", "test", "fixture")): + demo_test_run_count += 1 + return { + "command": meta, + "count": len(runs), + "status_counts": dict(sorted(status_counts.items())), + "health_counts": dict(sorted(health_counts.items())), + "role_counts": dict(sorted(role_counts.items())), + "runtime_counts": dict(sorted(runtime_counts.items())), + "stale_count": stale_count, + "failed_count": failed_count, + "running_count": running_count, + "demo_test_run_count": demo_test_run_count, + } + + +def collect_agent_work_counts() -> dict[str, Any]: + payload, meta = run_json_command(["./scripts/cento.sh", "agent-work", "runs", "--json"], timeout=180) + return aggregate_agent_runs(payload, meta) + + +def collect_codex_sqlite_counts(path: Path | None = None) -> dict[str, Any]: + db_path = path or (Path.home() / ".codex" / "logs_2.sqlite") + if not db_path.exists(): + return {"exists": False, "path": str(db_path)} + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=2) + try: + columns = [str(row[1]) for row in conn.execute("pragma table_info(logs)").fetchall()] + row_count = int(conn.execute("select count(*) from logs").fetchone()[0]) + level_counts = { + str(level): int(count) + for level, count in conn.execute("select level, count(*) from logs group by level order by count(*) desc").fetchall() + } + target_counts = { + str(target): int(count) + for target, count in conn.execute( + "select target, count(*) from logs group by target order by count(*) desc limit 20" + ).fetchall() + } + finally: + conn.close() + except sqlite3.Error as exc: + return {"exists": True, "path": str(db_path), "error": type(exc).__name__} + return { + "exists": True, + "path": str(db_path), + "bytes": db_path.stat().st_size, + "columns": columns, + "row_count": row_count, + "level_counts": level_counts, + "error_count": int(level_counts.get("ERROR", 0) + level_counts.get("error", 0)), + "top_targets": target_counts, + } + + +def count_skill_mentions_in_file(path: Path, terms: list[str]) -> dict[str, Any]: + counts: Counter[str] = Counter() + if not path.exists(): + return {"exists": False, "path": str(path), "bytes": 0, "counts": dict(counts)} + try: + with path.open("r", encoding="utf-8", errors="ignore") as handle: + for line in handle: + for term in terms: + if term in line: + counts[term] += line.count(term) + except OSError as exc: + return {"exists": True, "path": str(path), "bytes": path.stat().st_size, "error": type(exc).__name__, "counts": dict(counts)} + return {"exists": True, "path": str(path), "bytes": path.stat().st_size, "counts": dict(sorted(counts.items()))} + + +def collect_skill_usage_counts() -> dict[str, Any]: + sources = [ + Path.home() / ".codex" / "history.jsonl", + Path.home() / ".codex" / "log" / "codex-tui.log", + ] + source_counts = [count_skill_mentions_in_file(path, SKILL_TERMS) for path in sources] + totals: Counter[str] = Counter() + for source in source_counts: + for term, count in (source.get("counts") or {}).items(): + totals[str(term)] += int(count) + return { + "terms": SKILL_TERMS, + "sources": source_counts, + "total_counts": dict(sorted(totals.items())), + } + + +def collect_cento_native_skill_drift() -> dict[str, Any]: + installed_root = Path.home() / ".codex" / "skills" / "cento-native" + repo_root = ROOT / "skills" / "codex" / "cento-native" + rows: list[dict[str, Any]] = [] + for relative in ("SKILL.md", "references/routing.md"): + installed = file_fingerprint(installed_root / relative) + repo = file_fingerprint(repo_root / relative) + rows.append( + { + "relative_path": relative, + "installed": installed, + "repo": repo, + "in_sync": bool(installed.get("exists") and repo.get("exists") and installed.get("sha256") == repo.get("sha256")), + } + ) + return { + "installed_root": str(installed_root), + "repo_root": rel(repo_root), + "files": rows, + "drift_count": len([item for item in rows if not item["in_sync"]]), + } + + +def collect_routing_raw_counts(crontab_file: str = "") -> dict[str, Any]: + return { + "schema_version": "cento.routing_nativeness.raw_counts.v1", + "collected_at": now_iso(), + "privacy": { + "mode": "counts-only", + "raw_prompt_or_log_excerpts": False, + "notes": "Collectors persist aggregate counts, command metadata, hashes, and status fields only.", + }, + "git": collect_git_counts(), + "cron": routing_cron_status(crontab_file), + "tools": collect_tool_registry_counts(), + "cli_docs": collect_cli_docs_counts(), + "walk_autopilot": collect_walk_autopilot_status(), + "self_improve": collect_self_improve_status(), + "agent_work": collect_agent_work_counts(), + "codex_observability": collect_codex_sqlite_counts(), + "skill_usage": collect_skill_usage_counts(), + "cento_native_skill_drift": collect_cento_native_skill_drift(), + } + + +def routing_action(action_id: str, severity: str, reason: str, change: str, *, agent_work: bool = True) -> dict[str, Any]: + return { + "id": action_id, + "severity": severity, + "reason": reason, + "recommended_change": change, + "agent_work": agent_work, + } + + +def decide_routing_changes(raw: dict[str, Any], *, dirty_before: int, dirty_after: int) -> dict[str, Any]: + actions: list[dict[str, Any]] = [] + cron = raw.get("cron") if isinstance(raw.get("cron"), dict) else {} + if not bool(cron.get("installed")): + actions.append( + routing_action( + "install_routing_cron", + "medium", + "The lightweight routing nativeness loop is not installed in crontab.", + "Install the marked cron block at a four-hour cadence after deterministic validation passes.", + ) + ) + + self_improve = raw.get("self_improve") if isinstance(raw.get("self_improve"), dict) else {} + self_status = str(self_improve.get("status") or "unknown").lower() + validation_status = str(self_improve.get("validation_status") or "unknown").lower() + promotion = str(self_improve.get("promotion_recommendation") or "unknown").lower() + if self_status in {"unknown", "failed", "degraded", "partial", "incomplete"} or validation_status in {"unknown", "failed"} or promotion in {"unknown", "repair_pipeline_first"}: + actions.append( + routing_action( + "repair_self_improve_before_heavy_cron", + "high", + f"Self-improvement status={self_status}, validation={validation_status}, promotion={promotion}.", + "Repair the nightly self-improvement artifacts and gates before installing or relying on the heavier ProReq cron path.", + ) + ) + + drift = raw.get("cento_native_skill_drift") if isinstance(raw.get("cento_native_skill_drift"), dict) else {} + drift_count = int(drift.get("drift_count") or 0) + if drift_count: + actions.append( + routing_action( + "sync_cento_native_skill", + "high", + f"{drift_count} installed/repo cento-native skill file(s) differ or are missing.", + "Sync installed and repo skill copies, then validate routing intent examples for Docs, command docs, analysis, implementation, and tasking.", + ) + ) + + tools = raw.get("tools") if isinstance(raw.get("tools"), dict) else {} + if int(tools.get("walk_autopilot_routing_command_count") or 0) < 4: + actions.append( + routing_action( + "register_routing_commands", + "high", + "The tool registry does not expose the routing run/status/cron command surface.", + "Register the walk-autopilot routing commands and artifact locations in data/tools.json and the human tool index.", + ) + ) + + cli_docs = raw.get("cli_docs") if isinstance(raw.get("cli_docs"), dict) else {} + if not bool(cli_docs.get("human_routing_doc_exists")): + actions.append( + routing_action( + "write_human_routing_docs", + "medium", + "No human-facing routing nativeness loop document exists under docs/.", + "Add a readable operator page that explains cadence, authority, artifacts, privacy boundaries, and next iteration rules.", + ) + ) + + agent_work = raw.get("agent_work") if isinstance(raw.get("agent_work"), dict) else {} + stale_count = int(agent_work.get("stale_count") or 0) + demo_test_count = int(agent_work.get("demo_test_run_count") or 0) + if stale_count or demo_test_count: + actions.append( + routing_action( + "agent_work_hygiene_cleanup", + "medium", + f"Agent run inventory has stale_count={stale_count} and demo_test_run_count={demo_test_count}.", + "Queue a bounded hygiene cleanup to archive stale historical runs, identify active blockers, and keep demo/test inventory from hiding live work.", + ) + ) + + codex = raw.get("codex_observability") if isinstance(raw.get("codex_observability"), dict) else {} + error_count = int(codex.get("error_count") or 0) + if error_count > 50: + actions.append( + routing_action( + "codex_error_observability", + "medium", + f"Codex local observability database contains {error_count} ERROR rows.", + "Summarize error targets by count, map repeated targets to Cento skills or routing gaps, and avoid storing raw log bodies.", + ) + ) + elif error_count: + actions.append( + routing_action( + "codex_error_observability", + "low", + f"Codex local observability database contains {error_count} ERROR rows.", + "Track ERROR trend across the next two routing iterations before creating a repair task.", + agent_work=False, + ) + ) + + agent_work_allowed = True + if dirty_before != dirty_after: + agent_work_allowed = False + actions.append( + routing_action( + "dirty_worktree_changed_during_loop", + "high", + f"Git dirty count changed from {dirty_before} to {dirty_after} while collecting routing stats.", + "Do not create or update Agent Work from this run; inspect concurrent local edits first.", + agent_work=False, + ) + ) + + severity_counts = Counter(str(item.get("severity") or "unknown") for item in actions) + actionable_count = len([item for item in actions if bool(item.get("agent_work"))]) + return { + "schema_version": "cento.routing_nativeness.decision.v1", + "decided_at": now_iso(), + "authority": "report_then_task", + "cron_may_plan": True, + "cron_may_implement": False, + "agent_work_allowed": agent_work_allowed, + "dirty_count_before": dirty_before, + "dirty_count_after": dirty_after, + "summary": { + "action_count": len(actions), + "actionable_count": actionable_count, + "severity_counts": dict(sorted(severity_counts.items())), + }, + "actions": actions, + "next_iteration": routing_next_iteration(actions), + } + + +def routing_next_iteration(actions: list[dict[str, Any]]) -> list[str]: + ids = {str(item.get("id") or "") for item in actions} + steps = [ + "Let the four-hour loop collect at least two samples, then compare action id stability and severity movement.", + "Keep collectors counts-only; add a new counter only when it changes a routing decision.", + "Create implementation work through Agent Work, not directly from cron.", + ] + if "repair_self_improve_before_heavy_cron" in ids: + steps.append("Repair the nightly self-improvement gate before enabling any heavier live ProReq automation.") + if "sync_cento_native_skill" in ids: + steps.append("Make the installed cento-native skill and repo copy byte-identical, then re-run routing stats.") + if "agent_work_hygiene_cleanup" in ids: + steps.append("Run a bounded Agent Work hygiene cleanup and measure stale/demo/test counts in the next sample.") + if "codex_error_observability" in ids: + steps.append("Track Codex ERROR count and top targets without storing log bodies or prompt text.") + return steps + + +def write_routing_report(path: Path, raw: dict[str, Any], decision: dict[str, Any], agent_request: dict[str, Any]) -> None: + tools = raw.get("tools") if isinstance(raw.get("tools"), dict) else {} + agent_work = raw.get("agent_work") if isinstance(raw.get("agent_work"), dict) else {} + codex = raw.get("codex_observability") if isinstance(raw.get("codex_observability"), dict) else {} + skill_usage = raw.get("skill_usage") if isinstance(raw.get("skill_usage"), dict) else {} + actions = decision.get("actions") if isinstance(decision.get("actions"), list) else [] + lines = [ + "# Routing Nativeness Loop Decision Report", + "", + "## Summary", + "", + f"- Collected at: `{raw.get('collected_at')}`", + f"- Authority: `{decision.get('authority')}`", + f"- Cron may implement: `{decision.get('cron_may_implement')}`", + f"- Actions: `{decision.get('summary', {}).get('action_count', 0)}`", + f"- Actionable through Agent Work: `{decision.get('summary', {}).get('actionable_count', 0)}`", + "", + "## Counts", + "", + f"- Git dirty count: `{raw.get('git', {}).get('dirty_count')}`", + f"- Routing cron installed: `{raw.get('cron', {}).get('installed')}`", + f"- Registered tools: `{tools.get('tool_count')}`", + f"- Walk Autopilot routing commands registered: `{tools.get('walk_autopilot_routing_command_count')}`", + f"- Agent runs observed: `{agent_work.get('count')}`", + f"- Agent stale count: `{agent_work.get('stale_count')}`", + f"- Agent demo/test run count: `{agent_work.get('demo_test_run_count')}`", + f"- Codex log rows: `{codex.get('row_count')}`", + f"- Codex ERROR rows: `{codex.get('error_count')}`", + f"- Skill usage terms tracked: `{len(skill_usage.get('terms') or [])}`", + "", + "## Decisions", + "", + ] + if actions: + for item in actions: + lines.extend( + [ + f"### {item.get('id')}", + "", + f"- Severity: `{item.get('severity')}`", + f"- Agent Work: `{item.get('agent_work')}`", + f"- Reason: {item.get('reason')}", + f"- Recommended change: {item.get('recommended_change')}", + "", + ] + ) + else: + lines.extend(["- No routing changes recommended by this sample.", ""]) + lines.extend( + [ + "## Agent Work Handoff", + "", + f"- Status: `{agent_request.get('status')}`", + f"- Issue: `{agent_request.get('issue_id') or ''}`", + f"- Story manifest: `{agent_request.get('story_manifest') or ''}`", + "", + "## Next Iteration", + "", + markdown_list([str(item) for item in decision.get("next_iteration") or []]), + "", + "## Privacy Boundary", + "", + "This report is counts-only. It does not persist prompt text, log bodies, command stdout payloads, or raw Agent Work issue subjects.", + "", + ] + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def write_next_iteration(path: Path, decision: dict[str, Any]) -> None: + lines = [ + "# Routing Nativeness Next Iteration", + "", + "## Operating Rule", + "", + "The scheduled loop gathers counts, writes a decision report, and creates or updates one bounded Agent Work task when an actionable change is detected. It does not implement changes from cron.", + "", + "## Next Steps", + "", + markdown_list([str(item) for item in decision.get("next_iteration") or []]), + "", + "## Promotion Check", + "", + "- Two consecutive runs should agree on top action ids before increasing automation.", + "- Any new collector must prove it changes a routing decision and remains counts-only.", + "- Heavy ProReq or live worker automation remains gated behind explicit operator action.", + "", + ] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def routing_story_payload(decision: dict[str, Any], run_dir: Path) -> dict[str, Any]: + actions = [item for item in decision.get("actions") or [] if isinstance(item, dict) and bool(item.get("agent_work"))] + acceptance = [ + f"{item.get('id')}: {item.get('recommended_change')}" + for item in actions + ] or ["No implementation action is required; keep the routing report as evidence."] + return { + "schema_version": "1.0", + "issue": {"id": 0, "title": "Cento routing nativeness loop follow-up", "package": ROUTING_AGENT_WORK_PACKAGE}, + "lane": {"owner": "walk-autopilot", "node": "linux", "agent": "", "role": "coordinator"}, + "paths": {"run_dir": rel(run_dir)}, + "scope": { + "goal": "Resolve the bounded routing and Cento-native follow-up actions identified by the lightweight scheduled routing loop.", + "acceptance": acceptance, + }, + "expected_outputs": [ + { + "path": rel(run_dir / "decision_report.md"), + "description": "Counts-only routing decision report that explains the selected follow-up actions.", + "owner": "coordinator", + "required": True, + }, + { + "path": rel(run_dir / "next_iteration.md"), + "description": "Next iteration plan for the scheduled routing nativeness loop.", + "owner": "coordinator", + "required": True, + }, + ], + "validation": { + "manifest": rel(run_dir / "validation.json"), + "mode": "no-model", + "no_model_eligible": True, + "risk": "medium", + "escalation_triggers": ["missing_manifest", "failed_deterministic_command", "ambiguity"], + "commands": [ + "python3 -m pytest tests/test_routing_nativeness_loop.py tests/test_walk_autopilot.py tests/test_self_improvement_loop.py -q", + "python3 -m py_compile scripts/walk_autopilot.py", + "python3 -m json.tool data/tools.json", + "python3 -m json.tool data/cento-cli.json", + ], + }, + "deliverables": { + "manifest": rel(run_dir / "deliverables.json"), + "hub": rel(run_dir / "start-here.html"), + }, + "review_gate": { + "required_sections": ["Delivered", "Validation", "Evidence", "Residual risk"], + "residual_risk_required": True, + }, + "metadata": { + "drafted_at": now_iso(), + "source": "walk-autopilot-routing-nativeness", + "decision_report": rel(run_dir / "decision_report.md"), + "action_ids": [str(item.get("id") or "") for item in actions], + }, + } + + +def extract_issue_id(payload: dict[str, Any]) -> int | None: + candidates = [payload.get("id"), payload.get("issue_id")] + issue = payload.get("issue") + if isinstance(issue, dict): + candidates.extend([issue.get("id"), issue.get("issue_id")]) + for value in candidates: + try: + issue_id = int(value) + except (TypeError, ValueError): + continue + if issue_id > 0: + return issue_id + return None + + +def routing_agent_note(run_dir: Path, decision: dict[str, Any]) -> str: + summary = decision.get("summary") if isinstance(decision.get("summary"), dict) else {} + return ( + "Routing nativeness loop wrote a new counts-only decision report. " + f"Report: {rel(run_dir / 'decision_report.md')}. " + f"Actions={summary.get('action_count', 0)} actionable={summary.get('actionable_count', 0)} " + f"severity_counts={summary.get('severity_counts', {})}." + ) + + +def upsert_routing_agent_work(run_dir: Path, decision: dict[str, Any], *, no_agent_work: bool) -> dict[str, Any]: + actionable = [item for item in decision.get("actions") or [] if isinstance(item, dict) and bool(item.get("agent_work"))] + story_path = run_dir / "agent-work-story.json" + write_json(story_path, routing_story_payload(decision, run_dir)) + if not actionable: + return {"status": "not_needed", "issue_id": None, "story_manifest": rel(story_path)} + if no_agent_work: + return {"status": "skipped_no_agent_work", "issue_id": None, "story_manifest": rel(story_path)} + if not bool(decision.get("agent_work_allowed")): + return {"status": "blocked_dirty_worktree_changed", "issue_id": None, "story_manifest": rel(story_path)} + + previous = read_json(ROUTING_LATEST_DIR / "agent_work_request.json") + previous_issue_id = extract_issue_id(previous) + note = routing_agent_note(run_dir, decision) + if previous_issue_id: + show_payload, show_meta = run_json_command(["./scripts/cento.sh", "agent-work", "show", str(previous_issue_id), "--json"], timeout=60) + issue_status = str(show_payload.get("status") or "").lower() + if int(show_meta.get("exit_code") or 0) == 0 and issue_status not in {"done", "closed"}: + update_payload, update_meta = run_json_command( + ["./scripts/cento.sh", "agent-work", "update", str(previous_issue_id), "--note", note, "--json"], + timeout=90, + ) + if int(update_meta.get("exit_code") or 0) == 0: + return { + "status": "updated", + "issue_id": extract_issue_id(update_payload) or previous_issue_id, + "story_manifest": rel(story_path), + "command": update_meta, + } + + title = "Cento routing nativeness loop follow-up" + description = ( + "Counts-only scheduled routing loop identified actionable Cento-native follow-up work. " + f"Decision report: {rel(run_dir / 'decision_report.md')}" + ) + create_payload, create_meta = run_json_command( + [ + "./scripts/cento.sh", + "agent-work", + "create", + "--title", + title, + "--description", + description, + "--node", + "linux", + "--role", + "coordinator", + "--package", + ROUTING_AGENT_WORK_PACKAGE, + "--manifest", + rel(story_path), + "--owns", + "scripts/walk_autopilot.py", + "--owns", + "data/tools.json", + "--owns", + "docs/ai-routing-nativeness-loop.md", + "--json", + ], + timeout=120, + ) + issue_id = extract_issue_id(create_payload) + return { + "status": "created" if int(create_meta.get("exit_code") or 0) == 0 and issue_id else "create_failed", + "issue_id": issue_id, + "story_manifest": rel(story_path), + "command": create_meta, + } + + +def latest_routing_run_dir() -> Path | None: + if not ROUTING_RUN_ROOT.exists(): + return None + runs = [path for path in ROUTING_RUN_ROOT.glob("routing-native-*") if path.is_dir()] + return sorted(runs)[-1] if runs else None + + +def mirror_routing_latest(run_dir: Path) -> None: + ROUTING_LATEST_DIR.parent.mkdir(parents=True, exist_ok=True) + if ROUTING_LATEST_DIR.exists() or ROUTING_LATEST_DIR.is_symlink(): + if ROUTING_LATEST_DIR.is_dir() and not ROUTING_LATEST_DIR.is_symlink(): + shutil.rmtree(ROUTING_LATEST_DIR) + else: + ROUTING_LATEST_DIR.unlink() + shutil.copytree(run_dir, ROUTING_LATEST_DIR) + + +def latest_agent_pool_payload() -> dict[str, Any]: + return read_json(STATE_DIR / "agent-pool-kick-latest.json") + + +def live_failure_issue_ids(payload: dict[str, Any]) -> list[int]: + rows = payload.get("failed_launches") or payload.get("launched") or [] + ids: list[int] = [] + if not isinstance(rows, list): + return ids + for item in rows: + if not isinstance(item, dict): + continue + try: + issue_id = int(item.get("issue") or 0) + except (TypeError, ValueError): + continue + if issue_id > 0 and issue_id not in ids: + ids.append(issue_id) + return ids + + +def manifest_gaps_for_agent_pool_payload(payload: dict[str, Any], issue_ids: list[int] | None = None) -> list[dict[str, Any]]: + rows = payload.get("launched") or [] + requested = set(issue_ids or []) + gaps: list[dict[str, Any]] = [] + if not isinstance(rows, list): + return gaps + for item in rows: + if not isinstance(item, dict): + continue + try: + issue_id = int(item.get("issue") or 0) + except (TypeError, ValueError): + continue + if issue_id <= 0 or (requested and issue_id not in requested): + continue + story = ROOT / "workspace" / "runs" / "agent-work" / str(issue_id) / "story.json" + validation = ROOT / "workspace" / "runs" / "agent-work" / str(issue_id) / "validation.json" + story_missing = not story.exists() + validation_missing = not validation.exists() + if story_missing or validation_missing: + gaps.append( + { + "issue": issue_id, + "lane": item.get("lane"), + "subject": item.get("subject"), + "story_manifest": rel(story), + "validation_manifest": rel(validation), + "story_missing": story_missing, + "validation_missing": validation_missing, + } + ) + return gaps + + +def classify_agent_pool_live_failure(live_result: dict[str, Any], payload: dict[str, Any], gaps: list[dict[str, Any]]) -> str: + text = "\n".join( + [ + str(live_result.get("stdout_tail") or ""), + str(live_result.get("stderr_tail") or ""), + json.dumps(payload, sort_keys=True) if payload else "", + ] + ).lower() + if bool(live_result.get("timed_out")): + return "agent_pool_live_timeout" + if "canonical story manifest is missing" in text or "story manifest is missing" in text or gaps: + return "missing_canonical_manifest" + if "dispatch preflight blocked" in text or "preflight" in text: + return "dispatch_preflight_blocked" + reason = payload.get("reason_summary", {}).get("primary_reason") if isinstance(payload.get("reason_summary"), dict) else "" + if reason: + return f"agent_pool_{str(reason).replace('-', '_')}" + return "agent_pool_live_launch_failed" + + +def incident_markdown(payload: dict[str, Any]) -> str: + lines = [ + f"# Agent Pool Live Dispatch Incident", + "", + f"- Class: `{payload.get('incident_class')}`", + f"- Status: `{payload.get('status')}`", + f"- Loop: `{payload.get('loop')}`", + f"- Opened: `{payload.get('opened_at')}`", + "", + "## Summary", + "", + str(payload.get("summary") or "Live worker dispatch failed and was handled by the walk autopilot incident path."), + "", + "## Candidate Issues", + "", + ] + issues = payload.get("issue_ids") or [] + if issues: + lines.extend(f"- `{issue}`" for issue in issues) + else: + lines.append("- None captured.") + lines.extend(["", "## Manifest Gaps", ""]) + gaps = payload.get("manifest_gaps") or [] + if gaps: + for item in gaps: + lines.append( + f"- #{item.get('issue')} `{item.get('lane') or '-'}` story_missing={item.get('story_missing')} validation_missing={item.get('validation_missing')}" + ) + else: + lines.append("- None detected.") + lines.extend(["", "## Attempts", ""]) + for item in payload.get("attempts") or []: + lines.append(f"- `{item.get('name')}` exit={item.get('exit_code')} timed_out={bool(item.get('timed_out'))}") + lines.extend(["", "## Resolution", "", str(payload.get("resolution") or "Pending.")]) + return "\n".join(lines).rstrip() + "\n" + + +def write_agent_pool_incident(run_dir: Path, payload: dict[str, Any]) -> None: + incident_dir = Path(str(payload["incident_dir"])) + if not incident_dir.is_absolute(): + incident_dir = ROOT / incident_dir + write_json(incident_dir / "incident.json", payload) + (incident_dir / "notes.md").write_text(incident_markdown(payload), encoding="utf-8") + attempts = payload.get("attempts") or [] + with (incident_dir / "attempts.jsonl").open("w", encoding="utf-8") as handle: + for item in attempts: + handle.write(json.dumps(item, sort_keys=True, separators=(",", ":")) + "\n") + + +def append_incident_history(run_dir: Path, payload: dict[str, Any]) -> None: + append_jsonl( + run_dir / "incident-history.jsonl", + { + "schema_version": "cento.walk_autopilot.incident_history.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "loop": payload.get("loop"), + "incident_class": payload.get("incident_class"), + "status": payload.get("status"), + "issue_ids": payload.get("issue_ids") or [], + "incident_dir": rel(Path(str(payload.get("incident_dir") or ""))), + }, + ) + + +def consecutive_unresolved_incident_count(run_dir: Path, incident_class: str) -> int: + records = spend_ledger.read_jsonl(run_dir / "incident-history.jsonl") + count = 0 + for item in reversed(records): + if str(item.get("incident_class") or "") != incident_class: + break + if str(item.get("status") or "") == "recovered": + break + count += 1 + return count + + +def self_improvement_story_payload(incident_payload: dict[str, Any], story_run_dir: str) -> dict[str, Any]: + incident_class = str(incident_payload.get("incident_class") or "agent_pool_live_launch_failed") + title = f"Repair recurring walk autopilot incident: {incident_class}" + issue_ids = ", ".join(str(item) for item in incident_payload.get("issue_ids") or []) or "none captured" + return { + "schema_version": "1.0", + "issue": {"id": 0, "title": title, "package": "agent-ops"}, + "lane": {"owner": "walk-autopilot", "node": "linux", "agent": "", "role": "builder"}, + "paths": {"run_dir": story_run_dir}, + "scope": { + "goal": f"Eliminate recurring live worker dispatch incident class `{incident_class}` observed during Walk Autopilot. Candidate issues: {issue_ids}.", + "acceptance": [ + "Incident classification, repair, retry, and documentation remain deterministic and covered by focused tests.", + "Live worker dispatch keeps trying bounded repairs instead of falling back to proof-only loops.", + ], + }, + "expected_outputs": [ + { + "path": "docs/agent-work-live-dispatch-incident.md", + "description": "Operator runbook for recurring live worker dispatch incidents.", + "owner": "builder", + "required": True, + }, + { + "path": "tests/test_walk_autopilot.py", + "description": "Focused regression coverage for incident handling behavior.", + "owner": "builder", + "required": True, + }, + ], + "validation": { + "manifest": f"{story_run_dir}/validation.json", + "mode": "no-model", + "no_model_eligible": True, + "risk": "medium", + "escalation_triggers": ["missing_manifest", "failed_deterministic_command", "ambiguity"], + "commands": [ + "python3 -m pytest tests/test_walk_autopilot.py tests/test_agent_pool_kick.py", + "python3 -m json.tool data/tools.json", + ], + }, + "deliverables": { + "manifest": f"{story_run_dir}/deliverables.json", + "hub": f"{story_run_dir}/start-here.html", + }, + "review_gate": { + "required_sections": ["Delivered", "Validation", "Evidence", "Residual risk"], + "residual_risk_required": True, + }, + "metadata": { + "drafted_at": now_iso(), + "source": "walk-autopilot-incident-followup", + "incident_class": incident_class, + }, + } + + +def maybe_create_self_improvement_followup(run_dir: Path, incident_payload: dict[str, Any]) -> dict[str, Any]: + incident_class = str(incident_payload.get("incident_class") or "") + followups_path = run_dir / "incidents" / "self-improvement-followups.json" + followups = read_json(followups_path) + existing = followups.get(incident_class) if isinstance(followups, dict) else None + if existing: + return {"status": "existing", "followup": existing} + + incident_dir = Path(str(incident_payload["incident_dir"])) + if not incident_dir.is_absolute(): + incident_dir = ROOT / incident_dir + story_run_dir = rel(incident_dir / "self-improvement") + story_path = incident_dir / "self-improvement-story.json" + story_path.write_text(json.dumps(self_improvement_story_payload(incident_payload, story_run_dir), indent=2, sort_keys=True) + "\n", encoding="utf-8") + title = f"Repair recurring walk autopilot incident: {incident_class}" + description = ( + f"Walk Autopilot observed the `{incident_class}` live worker dispatch incident in consecutive loops. " + f"Incident bundle: {rel(incident_dir)}" + ) + result = run_command( + [ + "./scripts/cento.sh", + "agent-work", + "create", + "--title", + title, + "--description", + description, + "--node", + "linux", + "--role", + "builder", + "--package", + "agent-ops", + "--manifest", + rel(story_path), + "--json", + ], + timeout=120, + ) + record = { + "status": "created" if int(result.get("exit_code") or 0) == 0 else "create_failed", + "story_manifest": rel(story_path), + "command": result.get("command_text"), + "exit_code": result.get("exit_code"), + "stdout_tail": result.get("stdout_tail"), + "stderr_tail": result.get("stderr_tail"), + } + if not isinstance(followups, dict): + followups = {} + followups[incident_class] = record + write_json(followups_path, followups) + return record + + +def live_worker_blockers(commands: list[dict[str, Any]]) -> list[str]: + recovered_live_launch = any( + item.get("name") == "agent-pool-live-retry-after-incident" and int(item.get("exit_code") or 0) == 0 + for item in commands + ) + blockers: list[str] = [] + for item in commands: + if int(item.get("exit_code") or 0) == 0: + continue + if recovered_live_launch and item.get("name") == "agent-pool-live-launch": + continue + blockers.append(f"{item.get('name')}: exit {item.get('exit_code')}") + return blockers + + +def handle_agent_pool_live_incident( + *, + run_dir: Path, + loop_number: int, + args: argparse.Namespace, + live_result: dict[str, Any], + run_named: Any, +) -> dict[str, Any]: + payload = command_json_payload(live_result) or latest_agent_pool_payload() + issue_ids = live_failure_issue_ids(payload) + gaps = manifest_gaps_for_agent_pool_payload(payload, issue_ids) + incident_class = classify_agent_pool_live_failure(live_result, payload, gaps) + incident_dir = run_dir / "incidents" / f"loop-{loop_number:04d}-{incident_class}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" + attempts = [live_result] + incident_payload: dict[str, Any] = { + "schema_version": "cento.walk_autopilot.agent_pool_incident.v1", + "run_id": run_dir.name, + "loop": loop_number, + "opened_at": now_iso(), + "updated_at": now_iso(), + "incident_class": incident_class, + "status": "open", + "incident_dir": rel(incident_dir), + "summary": "Live worker dispatch failed; Walk Autopilot is applying bounded repair and retry instead of switching to proof-only loops.", + "issue_ids": issue_ids, + "manifest_gaps": gaps, + "agent_pool_payload": payload, + "attempts": attempts, + "resolution": "Repair and retry pending.", + } + write_agent_pool_incident(run_dir, incident_payload) + notify(args.notify_target, f"Walk incident loop {loop_number}: {incident_class}; repairing and retrying live workers.") + + repair_command = [ + "./scripts/cento.sh", + "agent-pool-kick", + "--repair-missing-manifests", + "--repair-apply", + "--repair-lanes", + "all", + "--repair-limit", + str(max(args.max_worker_launch, len(issue_ids), 1)), + "--max-launch", + "0", + "--dry-run", + ] + for issue_id in issue_ids: + repair_command.extend(["--repair-issue", str(issue_id)]) + repair = run_named("agent-pool-incident-repair-manifests", repair_command, 120) + attempts.append(repair) + + post_repair = run_named( + "agent-pool-incident-post-repair-dry-run", + ["./scripts/cento.sh", "agent-pool-kick", "--max-launch", str(args.max_worker_launch), "--dry-run"], + 90, + ) + attempts.append(post_repair) + + retry = run_named( + "agent-pool-live-retry-after-incident", + ["./scripts/cento.sh", "agent-pool-kick", "--max-launch", str(args.max_worker_launch)], + 240, + ) + attempts.append(retry) + + if int(retry.get("exit_code") or 0) == 0: + incident_payload["status"] = "recovered" + incident_payload["resolution"] = "Manifest repair and bounded retry succeeded in the same loop." + else: + recovery = run_named( + "agent-work-recovery-plan", + [ + "./scripts/cento.sh", + "agent-work", + "recovery-plan", + "--json", + "--run-dir", + rel(incident_dir / "recovery-plan"), + ], + 180, + ) + attempts.append(recovery) + incident_payload["status"] = "unresolved" + incident_payload["resolution"] = "Retry failed; recovery-plan artifacts were attached for the next loop." + + incident_payload["attempts"] = attempts + incident_payload["updated_at"] = now_iso() + write_agent_pool_incident(run_dir, incident_payload) + append_incident_history(run_dir, incident_payload) + + if incident_payload["status"] != "recovered" and consecutive_unresolved_incident_count(run_dir, incident_class) >= 2: + followup = maybe_create_self_improvement_followup(run_dir, incident_payload) + incident_payload["self_improvement_followup"] = followup + incident_payload["updated_at"] = now_iso() + write_agent_pool_incident(run_dir, incident_payload) + + notify( + args.notify_target, + f"Walk incident loop {loop_number}: {incident_class} status={incident_payload['status']} bundle={rel(incident_dir / 'notes.md')}", + ) + return incident_payload + + +def run_loop(run_dir: Path, loop_number: int, args: argparse.Namespace, previous_green: bool) -> dict[str, Any]: + loop_started = now_iso() + env = os.environ.copy() + env["CENTO_WALK_AUTOPILOT_RUN_DIR"] = str(run_dir) + env.setdefault("CENTO_AGENT_RUNTIME", "auto") + env.setdefault("CENTO_HARD_PROREQ_DISABLE_GPT_IMAGE_2", "1") + + before_spend = spend_summary(run_dir) + before_dirty = count_dirty_files() + commands: list[dict[str, Any]] = [] + + def run_named(name: str, command: list[str], timeout: int | None = None, extra_env: dict[str, str] | None = None) -> dict[str, Any]: + merged_env = dict(env) + if extra_env: + merged_env.update(extra_env) + result = run_command(command, timeout=timeout or args.command_timeout, env=merged_env) + record = command_record(name, result) + commands.append(record) + return record + + run_named("tools-json", ["python3", "-m", "json.tool", "data/tools.json"], timeout=30) + run_named("compute-policy", ["./scripts/cento.sh", "compute-policy", "show", "--json"], timeout=30) + run_named("factory-status", ["./scripts/cento.sh", "factory", "status", args.factory_run_id, "--json"], timeout=60) + run_named("factory-autopilot", ["./scripts/cento.sh", "factory", "autopilot", args.factory_run_id, "--dry-run", "--cycles", "1"], timeout=180) + run_named("parallel-delivery-validate", ["./scripts/cento.sh", "parallel-delivery", "validate", "--json"], timeout=180) + if getattr(args, "patch_swarm", False): + run_named( + "parallel-delivery-patch-swarm", + [ + "./scripts/cento.sh", + "parallel-delivery", + "patch-swarm", + "e2e", + "--run-id", + f"{run_dir.name}-loop-{loop_number:04d}", + "--candidate-target", + str(int(getattr(args, "patch_swarm_candidate_target", 100))), + "--max-parallel-agents", + str(int(getattr(args, "patch_swarm_max_parallel_agents", 5))), + "--providers", + str(getattr(args, "patch_swarm_providers", "codex-exec,claude-code,api-openai")), + "--fixture", + "--json", + ], + timeout=360, + ) + else: + commands.append({"name": "parallel-delivery-patch-swarm", "exit_code": 0, "skipped": True, "reason": "patch swarm autopilot disabled"}) + run_named( + "agent-work-hygiene", + ["./scripts/cento.sh", "agent-work-hygiene", "--out-dir", rel(run_dir / "agent-work-hygiene")], + timeout=120, + ) + review_unblock_mode = review_unblock_mode_for_args(args) + if review_unblock_mode == "off": + review_unblock_stage = { + "status": "skipped", + "exit_code": 0, + "mode": "off", + "summary": {}, + "applied_count": 0, + "failed_count": 0, + "operator_needed_count": 0, + } + commands.append({"name": "review-unblock", "exit_code": 0, "skipped": True, "reason": "review unblock disabled", "review_unblock": review_unblock_stage}) + else: + try: + review_unblock_stage = run_review_unblock_stage( + run_dir / "review-unblock" / f"loop-{loop_number:04d}", + mode=review_unblock_mode, + ) + commands.append(review_unblock_command_record(review_unblock_stage)) + except Exception as exc: # pragma: no cover - defensive loop containment + review_unblock_stage = { + "status": "failed", + "exit_code": 1, + "mode": review_unblock_mode, + "error": str(exc), + "summary": {}, + "applied_count": 0, + "failed_count": 1, + "operator_needed_count": 1, + } + commands.append( + { + "name": "review-unblock", + "exit_code": 1, + "command": ["internal", "review-unblock-stage"], + "command_text": f"internal review-unblock stage mode={review_unblock_mode}", + "stdout_tail": "", + "stderr_tail": str(exc), + "duration_seconds": 0, + "timed_out": False, + "review_unblock": review_unblock_stage, + } + ) + run_named("agent-pool-dry-run", ["./scripts/cento.sh", "agent-pool-kick", "--max-launch", str(args.max_worker_launch), "--dry-run"], timeout=90) + repair = run_named( + "agent-pool-repair-manifests", + [ + "./scripts/cento.sh", + "agent-pool-kick", + "--repair-missing-manifests", + "--repair-apply", + "--repair-lanes", + "all", + "--repair-limit", + str(args.max_worker_launch), + "--max-launch", + "0", + "--dry-run", + ], + timeout=90, + ) + if args.live_workers: + live_launch = run_named( + "agent-pool-live-launch", + ["./scripts/cento.sh", "agent-pool-kick", "--max-launch", str(args.max_worker_launch)], + timeout=240, + ) + if int(live_launch.get("exit_code") or 0) != 0: + handle_agent_pool_live_incident( + run_dir=run_dir, + loop_number=loop_number, + args=args, + live_result=live_launch, + run_named=run_named, + ) + else: + commands.append({"name": "agent-pool-live-launch", "exit_code": 0, "skipped": True, "reason": "live workers disabled"}) + + if loop_number % 2 == 0 or args.make_check_every == 1: + run_named("make-check", ["make", "check"], timeout=args.make_check_timeout) + else: + commands.append({"name": "make-check", "exit_code": 0, "skipped": True, "reason": "not scheduled this loop"}) + + after_repair_dry_run = run_named("agent-pool-post-repair-dry-run", ["./scripts/cento.sh", "agent-pool-kick", "--max-launch", str(args.max_worker_launch), "--dry-run"], timeout=90) + + current_spend = spend_summary(run_dir) + budget_gate = live_api_budget_gate(args, current_spend) + explicit_api_allowed = ( + args.allow_live_api + and bool(budget_gate.get("allowed")) + and loop_number % 3 == 0 + and previous_green + and float(current_spend.get("total_cost_usd") or 0.0) < args.soft_cap_usd + ) + if explicit_api_allowed: + run_named( + "parallel-delivery-self-improve", + ["./scripts/cento.sh", "parallel-delivery", "self-improve", "run", "--json"], + timeout=args.proreq_timeout, + extra_env={ + "CENTO_HARD_PROREQ_DISPATCH_PRO": "1", + REQUIRE_DASHBOARD_BUDGET_ENV: "1", + DASHBOARD_TOTAL_ENV: str(budget_gate.get("dashboard_total_spend_usd") or ""), + OPENAI_HARD_CAP_ENV: str(args.hard_cap_usd), + }, + ) + else: + commands.append( + { + "name": "parallel-delivery-self-improve", + "exit_code": 0, + "skipped": True, + "reason": "api gate closed or not scheduled", + "allow_live_api": bool(args.allow_live_api), + "previous_green": previous_green, + "total_cost_usd": current_spend.get("total_cost_usd"), + "soft_cap_usd": args.soft_cap_usd, + "budget_gate": budget_gate, + } + ) + + factory_record = spend_ledger.build_factory_record( + run_id=run_dir.name, + status="completed", + cost_usd=0.0, + artifact=f"loops/loop-{loop_number:04d}.md", + note="Factory status/autopilot dry-run loop cost is deterministic zero.", + ) + spend_ledger.append_record(run_dir / "spend-ledger.jsonl", factory_record, dedupe=False) + after_spend = spend_summary(run_dir) + spend_summary_record = { + "schema_version": "cento.walk_autopilot.spend_summary.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "loop": loop_number, + "category": "loop_summary", + "status": "completed", + "billable": False, + "cost_usd": 0.0, + "summary": after_spend, + } + append_jsonl(run_dir / "spend-ledger.jsonl", spend_summary_record) + + after_dirty = count_dirty_files() + changed_files = run_command(["git", "status", "--short"], timeout=20) + green = validation_green(commands) + blockers = live_worker_blockers(commands) + if after_dirty != before_dirty: + blockers.append(f"git dirty count changed from {before_dirty} to {after_dirty}; loop note records changed files") + if hard_cap_reached(after_spend, args.hard_cap_usd): + blockers.append(f"hard cap reached: {after_spend.get('total_cost_usd')} >= {args.hard_cap_usd}") + + metrics = { + "schema_version": "cento.walk_autopilot.metrics.v1", + "written_at": now_iso(), + "run_id": run_dir.name, + "loop": loop_number, + "started_at": loop_started, + "validation_green": green, + "command_count": len(commands), + "failed_command_count": len([item for item in commands if int(item.get("exit_code") or 0) != 0]), + "unresolved_failed_command_count": len(live_worker_blockers(commands)), + "spend_total_usd": after_spend.get("total_cost_usd"), + "factory_cost_usd": after_spend.get("factory_cost_usd"), + "api_cost_usd": after_spend.get("api_cost_usd"), + "dirty_count_before": before_dirty, + "dirty_count_after": after_dirty, + "hard_cap_reached": hard_cap_reached(after_spend, args.hard_cap_usd), + "soft_cap_warning": float(after_spend.get("total_cost_usd") or 0.0) >= args.soft_cap_usd, + "make_check_exit_code": next((item.get("exit_code") for item in commands if item.get("name") == "make-check"), None), + "make_check_skipped": bool(next((item.get("skipped") for item in commands if item.get("name") == "make-check"), False)), + "review_unblock_mode": review_unblock_stage.get("mode"), + "review_unblock_action_count": review_unblock_stage.get("summary", {}).get("action_count", 0), + "review_unblock_applied_count": review_unblock_stage.get("applied_count", 0), + "review_unblock_failed_count": review_unblock_stage.get("failed_count", 0), + "review_unblock_operator_needed_count": review_unblock_stage.get("operator_needed_count", 0), + } + append_jsonl(run_dir / "metrics.jsonl", metrics) + + loop_path = run_dir / "loops" / f"loop-{loop_number:04d}.md" + write_loop_markdown( + loop_path, + run_dir=run_dir, + loop_number=loop_number, + findings=loop_findings(commands, before_spend, after_spend), + breakthroughs=loop_breakthroughs(repair, after_repair_dry_run, commands), + copied_notes=copied_forward_notes(run_dir, loop_number), + next_steps=[ + "Keep Factory and API spend separated in ledger summaries.", + "Let Review/Unblock close, validate, requeue, or repair only when the decision report gives evidence.", + "Use repaired canonical manifests only as preflight scaffolds; workers must produce real evidence.", + "Run make check on the next scheduled even loop." if loop_number % 2 else "Review make check output before the next worker launch.", + ], + spend=after_spend, + validation=commands, + changed_files=str(changed_files.get("stdout_tail") or "").splitlines(), + blockers=blockers, + recommended_next_loop="Continue loop cadence if validation is green and hard cap is not reached; otherwise stop and inspect blockers.", + ) + update_handoff(run_dir, loop_number, metrics, blockers) + append_notes(run_dir, loop_number, blockers, after_spend) + notify(args.notify_target, f"Walk loop {loop_number}/{args.loops}: green={green} spend=${float(after_spend.get('total_cost_usd') or 0):.2f} blockers={len(blockers)}") + return {"green": green, "metrics": metrics, "blockers": blockers, "spend": after_spend, "loop_path": rel(loop_path)} + + +def loop_findings(commands: list[dict[str, Any]], before_spend: dict[str, Any], after_spend: dict[str, Any]) -> list[str]: + findings = [ + f"Spend before loop: ${float(before_spend.get('total_cost_usd') or 0.0):.4f}; after loop: ${float(after_spend.get('total_cost_usd') or 0.0):.4f}.", + "Factory dry-run work is recorded as a separate zero-cost factory ledger entry.", + ] + for item in commands: + if int(item.get("exit_code") or 0) != 0: + findings.append(f"{item.get('name')} failed with exit {item.get('exit_code')}.") + if item.get("name") == "review-unblock": + stage = as_dict(item.get("review_unblock")) + summary = as_dict(stage.get("summary")) + findings.append( + "Review/Unblock stage " + f"mode={stage.get('mode', 'unknown')} actions={summary.get('action_count', 0)} " + f"applied={stage.get('applied_count', 0)} operator_needed={stage.get('operator_needed_count', 0)}." + ) + return findings + + +def loop_breakthroughs(repair: dict[str, Any], post_repair: dict[str, Any], commands: list[dict[str, Any]] | None = None) -> list[str]: + items = [] + try: + payload = json.loads(str(repair.get("stdout_tail") or "{}")) + except json.JSONDecodeError: + payload = {} + repairs = payload.get("manifest_repairs") if isinstance(payload, dict) else [] + if repairs: + lanes = sorted({str(item.get("lane") or "unknown") for item in repairs if isinstance(item, dict)}) + items.append(f"Repaired {len(repairs)} missing live-lane manifest set(s): {', '.join(lanes) or 'unknown'}.") + else: + items.append("No missing live-lane story manifests needed repair in this loop.") + if int(post_repair.get("exit_code") or 0) == 0: + items.append("Post-repair pool dry-run completed.") + command_rows = commands or [] + if any(item.get("name") == "agent-pool-live-retry-after-incident" and int(item.get("exit_code") or 0) == 0 for item in command_rows): + items.append("Recovered a live worker dispatch incident with manifest repair and bounded retry.") + elif any(item.get("name") == "agent-work-recovery-plan" for item in command_rows): + items.append("Attached an agent-work recovery plan for an unresolved live dispatch incident.") + review_stage = next((as_dict(item.get("review_unblock")) for item in command_rows if item.get("name") == "review-unblock"), {}) + if review_stage: + applied = int(review_stage.get("applied_count") or 0) + failed = int(review_stage.get("failed_count") or 0) + operator_needed = int(review_stage.get("operator_needed_count") or 0) + if applied: + items.append(f"Review/Unblock applied {applied} evidence-gated action(s).") + elif operator_needed: + items.append(f"Review/Unblock identified {operator_needed} ambiguity item(s) for operator review.") + elif failed: + items.append("Review/Unblock wrote a failure record for the next recovery loop.") + return items + + +def markdown_list(items: list[str], empty: str = "None.") -> str: + if not items: + return f"- {empty}" + return "\n".join(f"- {item}" for item in items) + + +def write_loop_markdown( + path: Path, + *, + run_dir: Path, + loop_number: int, + findings: list[str], + breakthroughs: list[str], + copied_notes: str, + next_steps: list[str], + spend: dict[str, Any], + validation: list[dict[str, Any]], + changed_files: list[str], + blockers: list[str], + recommended_next_loop: str, +) -> None: + validation_lines = [ + f"{item.get('name')}: exit={item.get('exit_code', 'skipped')} skipped={bool(item.get('skipped'))}" + for item in validation + ] + lines = [ + f"# Walk Autopilot Loop {loop_number:04d}", + "", + "## Findings", + markdown_list(findings), + "", + "## Breakthroughs", + markdown_list(breakthroughs), + "", + "## Copied-Forward Notes", + copied_notes.strip() or "- None.", + "", + "## Next Steps", + markdown_list(next_steps), + "", + "## Next Big Things", + markdown_list( + [ + "Reliable spend accounting remains the first priority.", + "Hard ProReq/image fallback validation stays second.", + "Live worker dispatch unlock via canonical manifests stays third.", + ] + ), + "", + "## Spend", + "```json", + json.dumps(spend, indent=2, sort_keys=True), + "```", + "", + "## Validation", + markdown_list(validation_lines), + "", + "## Changed Files", + markdown_list(changed_files), + "", + "## Blockers", + markdown_list(blockers), + "", + "## Recommended Next Loop", + recommended_next_loop, + "", + ] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + missing = [section for section in REQUIRED_LOOP_SECTIONS if f"## {section}" not in path.read_text(encoding="utf-8")] + if missing: + raise RuntimeError(f"loop markdown is missing required sections: {missing}") + + +def update_handoff(run_dir: Path, loop_number: int, metrics: dict[str, Any], blockers: list[str]) -> None: + lines = [ + "# Walk Autopilot Handoff", + "", + f"- Run: `{run_dir.name}`", + f"- Latest loop: `{loop_number}`", + f"- Latest metrics: `{json.dumps(metrics, sort_keys=True)}`", + "", + "## Current Blockers", + markdown_list(blockers), + "", + "## Incident Runbook", + "`docs/agent-work-live-dispatch-incident.md`", + "", + "## Resume Command", + f"`./scripts/cento.sh walk-autopilot run --run-id {run_dir.name}`", + "", + ] + (run_dir / "handoff.md").write_text("\n".join(lines), encoding="utf-8") + + +def append_notes(run_dir: Path, loop_number: int, blockers: list[str], spend: dict[str, Any]) -> None: + with (run_dir / "notes.md").open("a", encoding="utf-8") as handle: + handle.write( + f"\n## Loop {loop_number:04d} Note\n\n" + f"- Spend total: ${float(spend.get('total_cost_usd') or 0.0):.4f}\n" + f"- Blockers: {len(blockers)}\n" + ) + + +def notify(target: str, message: str) -> None: + if not target: + return + subprocess.run(["./scripts/cento.sh", "notify", target, message], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=20) + + +def command_run(args: argparse.Namespace) -> int: + run_id = args.run_id or timestamp_id() + run_dir = RUN_ROOT / run_id + budget_gate = live_api_budget_gate(args, {}) + if not bool(budget_gate.get("allowed")): + print(json.dumps({"status": "blocked", "budget_gate": budget_gate}, indent=2, sort_keys=True), file=sys.stderr) + return 2 + init_run(run_dir, args) + notify(args.notify_target, f"Walk autopilot {run_id} starting: loops={args.loops} cadence={args.cadence_seconds}s hard_cap=${args.hard_cap_usd:.0f}") + previous_green = True + consecutive_make_failures = 0 + for loop_number in range(1, args.loops + 1): + started = time.monotonic() + result = run_loop(run_dir, loop_number, args, previous_green) + previous_green = bool(result["green"]) + make_ran = result["metrics"].get("make_check_skipped") is False + make_fail = make_ran and int(result["metrics"].get("make_check_exit_code") or 0) != 0 + if make_fail: + consecutive_make_failures += 1 + elif make_ran: + consecutive_make_failures = 0 + if result["metrics"].get("hard_cap_reached"): + notify(args.notify_target, f"Walk autopilot stopping: hard cap reached at loop {loop_number}.") + break + if consecutive_make_failures >= 2: + notify(args.notify_target, f"Walk autopilot stopping: make check failed twice by loop {loop_number}.") + break + if loop_number < args.loops and args.cadence_seconds > 0: + elapsed = time.monotonic() - started + sleep_seconds = max(0.0, args.cadence_seconds - elapsed) + if sleep_seconds: + time.sleep(sleep_seconds) + notify(args.notify_target, f"Walk autopilot {run_id} finished or detached loop exited. Handoff: {rel(run_dir / 'handoff.md')}") + print(json.dumps({"run_id": run_id, "run_dir": rel(run_dir), "handoff": rel(run_dir / "handoff.md")}, indent=2)) + return 0 + + +def command_start_tmux(args: argparse.Namespace) -> int: + run_id = args.run_id or timestamp_id() + budget_gate = live_api_budget_gate(args, {}) + if not bool(budget_gate.get("allowed")): + print(json.dumps({"status": "blocked", "budget_gate": budget_gate}, indent=2, sort_keys=True), file=sys.stderr) + return 2 + command = [ + "./scripts/cento.sh", + "walk-autopilot", + "run", + "--run-id", + run_id, + "--loops", + str(args.loops), + "--cadence-seconds", + str(args.cadence_seconds), + "--soft-cap-usd", + str(args.soft_cap_usd), + "--hard-cap-usd", + str(args.hard_cap_usd), + "--max-worker-launch", + str(args.max_worker_launch), + ] + dashboard_total = dashboard_total_spend_usd(args) + if dashboard_total is not None: + command.extend(["--dashboard-total-spend-usd", str(dashboard_total)]) + if args.live_workers: + command.append("--live-workers") + if args.allow_live_api: + command.append("--allow-live-api") + if getattr(args, "review_unblock_mode", ""): + command.extend(["--review-unblock-mode", args.review_unblock_mode]) + if getattr(args, "no_review_unblock", False): + command.append("--no-review-unblock") + if getattr(args, "patch_swarm", False): + command.append("--patch-swarm") + command.extend(["--patch-swarm-candidate-target", str(args.patch_swarm_candidate_target)]) + command.extend(["--patch-swarm-max-parallel-agents", str(args.patch_swarm_max_parallel_agents)]) + command.extend(["--patch-swarm-providers", args.patch_swarm_providers]) + if args.notify_target: + command.extend(["--notify-target", args.notify_target]) + session = args.session or f"walk-autopilot-{run_id[-16:]}" + result = subprocess.run(["tmux", "new-session", "-d", "-s", session, shlex.join(command)], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if result.returncode != 0: + print(result.stderr or result.stdout, file=sys.stderr) + return result.returncode + print(json.dumps({"run_id": run_id, "session": session, "command": shlex.join(command), "run_dir": rel(RUN_ROOT / run_id)}, indent=2)) + return 0 + + +def command_status(args: argparse.Namespace) -> int: + run_dir = RUN_ROOT / args.run_id if args.run_id else sorted(RUN_ROOT.glob("walk-autopilot-*"))[-1] + payload = { + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "config": read_json(run_dir / "config.json"), + "spend": spend_summary(run_dir), + "metrics_records": len(spend_ledger.read_jsonl(run_dir / "metrics.jsonl")), + "loops": [path.name for path in sorted((run_dir / "loops").glob("loop-*.md"))], + "incidents": [path.name for path in sorted(path for path in (run_dir / "incidents").glob("*") if path.is_dir())] if (run_dir / "incidents").exists() else [], + "handoff": rel(run_dir / "handoff.md"), + } + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +def command_review_unblock_run(args: argparse.Namespace) -> int: + run_id = args.run_id or timestamp_id("review-unblock") + run_dir = REVIEW_UNBLOCK_RUN_ROOT / run_id + stage = run_review_unblock_stage(run_dir, mode=args.mode) + mirror_review_unblock_latest(run_dir) + payload = { + "status": stage.get("status"), + "run_id": run_id, + "run_dir": rel(run_dir), + "latest_dir": rel(REVIEW_UNBLOCK_LATEST_DIR), + "decision_report": stage.get("decision_report"), + "summary": stage.get("summary"), + "applied_count": stage.get("applied_count", 0), + "failed_count": stage.get("failed_count", 0), + "operator_needed_count": stage.get("operator_needed_count", 0), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload["decision_report"])) + return int(stage.get("exit_code") or 0) + + +def review_unblock_status_payload() -> dict[str, Any]: + run_dir = latest_review_unblock_run_dir() + decision = read_json(run_dir / "decision.json") if run_dir else {} + results = read_json(run_dir / "results.json") if run_dir else {} + return { + "schema_version": "cento.review_unblock.status.v1", + "checked_at": now_iso(), + "latest_run": rel(run_dir) if run_dir else "", + "latest_dir": rel(REVIEW_UNBLOCK_LATEST_DIR) if REVIEW_UNBLOCK_LATEST_DIR.exists() else "", + "decision_summary": as_dict(decision.get("summary")) if isinstance(decision, dict) else {}, + "result_count": len(as_list(results.get("results"))) if isinstance(results, dict) else 0, + "decision_report": rel(run_dir / "decision_report.md") if run_dir and (run_dir / "decision_report.md").exists() else "", + } + + +def command_review_unblock_status(args: argparse.Namespace) -> int: + payload = review_unblock_status_payload() + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else json.dumps(payload["decision_summary"], sort_keys=True)) + return 0 + + +def command_routing_run(args: argparse.Namespace) -> int: + run_id = args.run_id or timestamp_id("routing-native") + run_dir = ROUTING_RUN_ROOT / run_id + run_dir.mkdir(parents=True, exist_ok=True) + dirty_before = count_dirty_files() + raw = collect_routing_raw_counts(args.crontab_file) + write_json(run_dir / "raw_counts.json", raw) + dirty_after = count_dirty_files() + decision = decide_routing_changes(raw, dirty_before=dirty_before, dirty_after=dirty_after) + write_json(run_dir / "decision.json", decision) + agent_request = upsert_routing_agent_work(run_dir, decision, no_agent_work=bool(args.no_agent_work)) + write_json(run_dir / "agent_work_request.json", agent_request) + write_routing_report(run_dir / "decision_report.md", raw, decision, agent_request) + write_next_iteration(run_dir / "next_iteration.md", decision) + metrics = { + "schema_version": "cento.routing_nativeness.metrics.v1", + "written_at": now_iso(), + "run_id": run_id, + "action_count": decision.get("summary", {}).get("action_count", 0), + "actionable_count": decision.get("summary", {}).get("actionable_count", 0), + "agent_work_status": agent_request.get("status"), + "dirty_count_before": dirty_before, + "dirty_count_after": dirty_after, + "cron_installed": raw.get("cron", {}).get("installed"), + "skill_drift_count": raw.get("cento_native_skill_drift", {}).get("drift_count"), + "codex_error_count": raw.get("codex_observability", {}).get("error_count"), + "agent_stale_count": raw.get("agent_work", {}).get("stale_count"), + } + append_jsonl(run_dir / "metrics.jsonl", metrics) + mirror_routing_latest(run_dir) + payload = { + "status": "completed", + "run_id": run_id, + "run_dir": rel(run_dir), + "latest_dir": rel(ROUTING_LATEST_DIR), + "decision_report": rel(run_dir / "decision_report.md"), + "agent_work_request": agent_request, + "summary": decision.get("summary"), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else rel(run_dir / "decision_report.md")) + return 0 + + +def routing_status_payload(crontab_file: str = "") -> dict[str, Any]: + run_dir = latest_routing_run_dir() + decision = read_json(run_dir / "decision.json") if run_dir else {} + agent_request = read_json(run_dir / "agent_work_request.json") if run_dir else {} + metrics_records = spend_ledger.read_jsonl(run_dir / "metrics.jsonl") if run_dir else [] + return { + "schema_version": "cento.routing_nativeness.status.v1", + "checked_at": now_iso(), + "cron": routing_cron_status(crontab_file), + "latest_run": rel(run_dir) if run_dir else "", + "latest_dir": rel(ROUTING_LATEST_DIR) if ROUTING_LATEST_DIR.exists() else "", + "decision_summary": decision.get("summary") if isinstance(decision.get("summary"), dict) else {}, + "agent_work_request": { + "status": agent_request.get("status"), + "issue_id": agent_request.get("issue_id"), + "story_manifest": agent_request.get("story_manifest"), + }, + "metrics_records": len(metrics_records), + } + + +def command_routing_status(args: argparse.Namespace) -> int: + payload = routing_status_payload(args.crontab_file) + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else json.dumps(payload["decision_summary"], sort_keys=True)) + return 0 + + +def command_routing_install_cron(args: argparse.Namespace) -> int: + block = routing_cron_block(args.every_hours) + current = read_crontab(args.crontab_file) + stripped = strip_routing_cron_block(current).rstrip() + updated = (stripped + "\n" if stripped else "") + block + if not args.dry_run: + write_crontab(updated, args.crontab_file) + payload = { + "status": "planned" if args.dry_run else "installed", + "dry_run": bool(args.dry_run), + "every_hours": args.every_hours, + "schedule": f"0 */{args.every_hours} * * *", + "cron_installed_before": ROUTING_CRON_BEGIN in current, + "crontab_file": args.crontab_file, + "log_path": rel(ROUTING_LOG_PATH), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload["status"]) + return 0 + + +def command_routing_uninstall_cron(args: argparse.Namespace) -> int: + current = read_crontab(args.crontab_file) + updated = strip_routing_cron_block(current) + if not args.dry_run: + write_crontab(updated, args.crontab_file) + payload = { + "status": "planned" if args.dry_run else "uninstalled", + "dry_run": bool(args.dry_run), + "cron_installed_before": ROUTING_CRON_BEGIN in current, + "crontab_file": args.crontab_file, + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload["status"]) + return 0 + + +def mirror_patch_swarm_autopilot_latest(run_dir: Path) -> None: + if PATCH_SWARM_AUTOPILOT_LATEST_DIR.exists() or PATCH_SWARM_AUTOPILOT_LATEST_DIR.is_symlink(): + if PATCH_SWARM_AUTOPILOT_LATEST_DIR.is_dir() and not PATCH_SWARM_AUTOPILOT_LATEST_DIR.is_symlink(): + shutil.rmtree(PATCH_SWARM_AUTOPILOT_LATEST_DIR) + else: + PATCH_SWARM_AUTOPILOT_LATEST_DIR.unlink() + PATCH_SWARM_AUTOPILOT_LATEST_DIR.parent.mkdir(parents=True, exist_ok=True) + try: + PATCH_SWARM_AUTOPILOT_LATEST_DIR.symlink_to(run_dir.resolve(), target_is_directory=True) + except OSError: + shutil.copytree(run_dir, PATCH_SWARM_AUTOPILOT_LATEST_DIR) + + +def command_patch_swarm_run(args: argparse.Namespace) -> int: + run_id = args.run_id or timestamp_id("patch-swarm-autopilot") + run_dir = PATCH_SWARM_AUTOPILOT_ROOT / run_id + run_dir.mkdir(parents=True, exist_ok=True) + command = [ + "./scripts/cento.sh", + "parallel-delivery", + "patch-swarm", + "e2e", + "--run-id", + run_id, + "--candidate-target", + str(args.candidate_target), + "--max-parallel-agents", + str(args.max_parallel_agents), + "--providers", + args.providers, + "--fixture", + "--json", + ] + result = run_command(command, timeout=args.timeout) + record = command_record("parallel-delivery-patch-swarm", result) + write_json(run_dir / "command_result.json", record) + try: + payload = json.loads(str(result.get("stdout_tail") or "{}")) + except json.JSONDecodeError: + payload = {} + summary = { + "schema_version": "cento.walk_autopilot.patch_swarm.v1", + "run_id": run_id, + "written_at": now_iso(), + "status": payload.get("status") or ("completed" if int(result.get("exit_code") or 1) == 0 else "blocked"), + "parallel_delivery_run": payload.get("run_dir") or f"workspace/runs/parallel-delivery/patch-swarm/{run_id}", + "candidate_count": payload.get("candidate_count", 0), + "selected_count": payload.get("selected_count", 0), + "estimated_cost_usd": payload.get("estimated_cost_usd", 0.0), + "safe_integrator_handoff": payload.get("safe_integrator_handoff", ""), + "ui_state": payload.get("ui_state", ""), + "decision_report": payload.get("decision_report", ""), + "command_result": rel(run_dir / "command_result.json"), + } + write_json(run_dir / "summary.json", summary) + (run_dir / "summary.md").write_text( + "\n".join( + [ + "# Patch Swarm Autopilot", + "", + f"- Run: `{run_id}`", + f"- Status: `{summary['status']}`", + f"- Candidates: `{summary['candidate_count']}`", + f"- Selected: `{summary['selected_count']}`", + f"- Estimated cost: `${float(summary['estimated_cost_usd'] or 0.0):.6f}`", + f"- Parallel delivery run: `{summary['parallel_delivery_run']}`", + f"- UI state: `{summary['ui_state'] or '-'}`", + f"- Decision report: `{summary['decision_report'] or '-'}`", + ] + ) + + "\n", + encoding="utf-8", + ) + mirror_patch_swarm_autopilot_latest(run_dir) + print(json.dumps(summary, indent=2, sort_keys=True) if args.json else rel(run_dir / "summary.md")) + return 0 if summary["status"] == "completed" else 1 + + +def command_patch_swarm_status(args: argparse.Namespace) -> int: + if args.run_id: + run_dir = PATCH_SWARM_AUTOPILOT_ROOT / args.run_id + else: + run_dir = PATCH_SWARM_AUTOPILOT_LATEST_DIR if PATCH_SWARM_AUTOPILOT_LATEST_DIR.exists() else None + summary = read_json(run_dir / "summary.json") if run_dir else {} + if not summary: + payload = {"schema_version": "cento.walk_autopilot.patch_swarm.status.v1", "status": "unknown", "latest_run": ""} + else: + payload = {"schema_version": "cento.walk_autopilot.patch_swarm.status.v1", "latest_run": rel(run_dir), **summary} + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else json.dumps(payload, sort_keys=True)) + return 0 + + +def factory_scale_install_cron_for_run(run_id: str, *, duration_hours: float | None = None, crontab_file: str = "", dry_run: bool = False) -> dict[str, Any]: + block = factory_scale_cron_block(run_id, duration_hours) + current = read_crontab(crontab_file) + stripped = strip_factory_scale_cron_block(current).rstrip() + updated = (stripped + "\n" if stripped else "") + block + if not dry_run: + write_crontab(updated, crontab_file) + run_dir = factory_scale_run_dir(run_id) + config = read_json(run_dir / "config.json") + schedule = str(config.get("tick_schedule") or "*/12 * * * *") + cron_payload = { + "schema_version": "cento.walk_autopilot.factory_scale.cron.v1", + "written_at": now_iso(), + "run_id": run_id, + "status": "planned" if dry_run else "installed", + "dry_run": bool(dry_run), + "schedule": schedule, + "batch_size": int(config.get("batch_size") or 1), + "lock_name": str(config.get("lock_name") or "factory-scale-final-test.lock"), + "duration_hours": duration_hours, + "cron_installed_before": FACTORY_SCALE_CRON_BEGIN in current, + "crontab_file": crontab_file, + "log_path": rel(FACTORY_SCALE_LOG_PATH), + "block": block, + } + if run_dir.exists(): + write_json(run_dir / "cron.json", cron_payload) + (run_dir / "cron.md").write_text( + "\n".join( + [ + "# Factory Scale Cron", + "", + f"- Status: `{cron_payload['status']}`", + f"- Schedule: `{cron_payload['schedule']}`", + f"- Batch size: `{cron_payload['batch_size']}`", + f"- Log: `{cron_payload['log_path']}`", + f"- Crontab file: `{crontab_file or 'system crontab'}`", + "", + "```cron", + block.rstrip(), + "```", + "", + ] + ), + encoding="utf-8", + ) + factory_scale_append_event(run_dir, "cron_planned" if dry_run else "cron_installed", {"crontab_file": crontab_file, "schedule": schedule, "batch_size": cron_payload["batch_size"]}) + return cron_payload + + +def command_factory_scale_start(args: argparse.Namespace) -> int: + preflight = factory_scale_no_overlap_preflight(args.run_id, args.crontab_file) + if bool(preflight.get("active")): + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.start.v1", + "status": "attached", + "reason": "existing active factory-scale/autopilot lane detected; start did not create a duplicate run", + "preflight": preflight, + "summary": preflight.get("latest_status") or preflight.get("target_status"), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload["reason"])) + return 0 + run_id = args.run_id or timestamp_id("factory-scale") + run_dir = factory_scale_run_dir(run_id) + if not (run_dir / "config.json").exists(): + factory_scale_init_run(run_dir, args) + cron_payload: dict[str, Any] = {"status": "skipped"} + if not bool(getattr(args, "no_install_cron", False)): + cron_payload = factory_scale_install_cron_for_run( + run_id, + duration_hours=float(args.duration_hours), + crontab_file=args.crontab_file, + dry_run=bool(args.dry_run), + ) + factory_scale_write_handoff(run_dir) + payload = { + "status": "started", + "run_id": run_id, + "run_dir": rel(run_dir), + "config": rel(run_dir / "config.json"), + "roadmap": rel(run_dir / "roadmap.md"), + "handoff": rel(run_dir / "handoff.md"), + "cron": cron_payload, + "summary": factory_scale_status_payload(run_id, args.crontab_file), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else rel(run_dir / "handoff.md")) + return 0 + + +def command_factory_scale_start_day(args: argparse.Namespace) -> int: + hard_limit = 10_000 + target_calls = int(args.target_proreq_calls) + max_calls = int(args.max_proreq_calls) + if target_calls <= 0 or max_calls <= 0: + print(json.dumps({"status": "blocked", "reason": "target and max ProReq-light calls must be positive"}, indent=2), file=sys.stderr) + return 2 + if max_calls > hard_limit: + print(json.dumps({"status": "blocked", "reason": f"--max-proreq-calls cannot exceed {hard_limit}"}, indent=2), file=sys.stderr) + return 2 + if target_calls > max_calls: + print(json.dumps({"status": "blocked", "reason": "--target-proreq-calls cannot exceed --max-proreq-calls"}, indent=2), file=sys.stderr) + return 2 + tick_minutes = max(1, min(59, int(args.tick_minutes))) + batch_size = max(1, int(args.batch_size)) + proreq_executions = factory_scale_executions_for_call_target(target_calls) + expected_calls = factory_scale_call_target_for_executions(proreq_executions) + if expected_calls > max_calls: + payload = { + "status": "blocked", + "reason": "derived ProReq-light command-call count would exceed --max-proreq-calls", + "target_proreq_calls": target_calls, + "derived_expected_proreq_calls": expected_calls, + "max_proreq_calls": max_calls, + } + print(json.dumps(payload, indent=2), file=sys.stderr) + return 2 + preflight = factory_scale_no_overlap_preflight(args.run_id, args.crontab_file) + if bool(preflight.get("active")): + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.day_start.v1", + "status": "attached", + "reason": "existing active factory-scale/autopilot lane detected; day start did not create a duplicate run", + "preflight": preflight, + "summary": preflight.get("latest_status") or preflight.get("target_status"), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload["reason"])) + return 0 + + run_id = args.run_id or timestamp_id("factory-scale-day") + run_dir = factory_scale_run_dir(run_id) + day_args = argparse.Namespace(**vars(args)) + day_args.run_id = run_id + day_args.proreq_executions = proreq_executions + day_args.min_proreq_calls = target_calls + day_args.patch_swarm = not bool(getattr(args, "no_patch_swarm", False)) + day_args.patch_swarm_candidate_target = int(args.patch_swarm_candidate_target) + day_args.patch_swarm_max_parallel_agents = int(args.patch_swarm_max_parallel_agents) + day_args.execute_proreq = False + day_args.proreq_command_timeout = int(args.proreq_command_timeout) + day_args.tick_schedule = f"*/{tick_minutes} * * * *" + day_args.batch_size = batch_size + day_args.run_mode = "day-scale" + day_args.lock_name = "factory-scale-day.lock" + day_args.target_proreq_calls = target_calls + day_args.max_proreq_calls = max_calls + if not (run_dir / "config.json").exists(): + factory_scale_init_run(run_dir, day_args) + cron_payload: dict[str, Any] = {"status": "skipped"} + if not bool(getattr(args, "no_install_cron", False)): + cron_payload = factory_scale_install_cron_for_run( + run_id, + duration_hours=float(args.duration_hours), + crontab_file=args.crontab_file, + dry_run=bool(args.dry_run), + ) + factory_scale_write_handoff(run_dir) + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.day_start.v1", + "status": "started", + "run_id": run_id, + "run_dir": rel(run_dir), + "target_proreq_calls": target_calls, + "max_proreq_calls": max_calls, + "proreq_executions": proreq_executions, + "expected_proreq_calls": expected_calls, + "batch_size": batch_size, + "tick_schedule": day_args.tick_schedule, + "expected_patch_swarm_runs": factory_scale_status_payload(run_id, args.crontab_file).get("expected_patch_swarm_runs"), + "expected_candidate_patch_receipts": factory_scale_status_payload(run_id, args.crontab_file).get("expected_candidate_patch_receipts"), + "cron": cron_payload, + "handoff": rel(run_dir / "handoff.md"), + "summary": factory_scale_status_payload(run_id, args.crontab_file), + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else rel(run_dir / "handoff.md")) + return 0 + + +def command_factory_scale_preflight(args: argparse.Namespace) -> int: + payload = factory_scale_no_overlap_preflight(args.run_id, args.crontab_file) + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload.get("decision", "unknown")) + return 0 + + +def command_factory_scale_advance(args: argparse.Namespace) -> int: + run_dir = factory_scale_run_dir(args.run_id) if args.run_id else latest_factory_scale_run_dir() + if not run_dir or not run_dir.exists(): + payload = {"schema_version": "cento.walk_autopilot.factory_scale.advance.v1", "status": "unknown", "reason": "no factory-scale run found"} + print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr) + return 2 + preflight = factory_scale_no_overlap_preflight(run_dir.name, args.crontab_file) + source_status = as_dict(preflight.get("target_status")) + if bool(preflight.get("active")): + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.advance.v1", + "status": "attached", + "reason": "existing active factory-scale/autopilot lane detected; advance did not write derived artifacts", + "preflight": preflight, + "summary": source_status, + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload["reason"])) + return 0 + if str(source_status.get("status") or "") != "completed" and not bool(getattr(args, "allow_incomplete", False)): + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.advance.v1", + "status": "blocked", + "reason": "factory-scale advance requires a completed run unless --allow-incomplete is passed", + "preflight": preflight, + "summary": source_status, + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload["reason"]), file=sys.stderr) + return 2 + live_guard = factory_scale_live_api_guard(args, run_dir) + payload = factory_scale_write_advance_artifacts( + run_dir, + preflight=preflight, + live_api_guard=live_guard, + promotion_limit=int(args.promotion_limit), + ) + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload["morning_report"]) + return 0 + + +def command_factory_scale_promote(args: argparse.Namespace) -> int: + run_dir = factory_scale_run_dir(args.run_id) if args.run_id else latest_factory_scale_run_dir() + if not run_dir or not run_dir.exists(): + payload = {"schema_version": "cento.walk_autopilot.factory_scale.factory_promotion.v1", "status": "unknown", "reason": "no factory-scale run found"} + print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr) + return 2 + preflight = factory_scale_no_overlap_preflight(run_dir.name, args.crontab_file) + source_status = as_dict(preflight.get("target_status")) + if bool(preflight.get("active")): + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.factory_promotion.v1", + "status": "attached", + "reason": "existing active factory-scale/autopilot lane detected; promotion did not create Factory work", + "preflight": preflight, + "summary": source_status, + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload["reason"])) + return 0 + if str(source_status.get("status") or "") != "completed" and not bool(getattr(args, "allow_incomplete", False)): + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.factory_promotion.v1", + "status": "blocked", + "reason": "factory-scale promotion requires a completed run unless --allow-incomplete is passed", + "preflight": preflight, + "summary": source_status, + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload["reason"]), file=sys.stderr) + return 2 + promotion_plan = resolve_cento_path(args.promotion_plan) if getattr(args, "promotion_plan", "") else None + payload = factory_scale_promote_to_factory( + run_dir, + promotion_plan_path=promotion_plan, + factory_run=args.factory_run, + apply=bool(args.apply), + validate_each=bool(args.validate_each), + branch=args.branch, + worktree=args.worktree, + limit=max(0, int(args.limit or 0)), + exclusive_paths=bool(args.exclusive_paths), + ) + if payload.get("status") == "blocked": + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload.get("reason", "blocked")), file=sys.stderr) + return 2 + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else str(payload.get("receipt") or payload.get("factory_run_dir"))) + return 0 + + +def factory_scale_tick_once(run_dir: Path, *, cron_lock_conflict: bool = False) -> tuple[dict[str, Any], int]: + config = read_json(run_dir / "config.json") + events = spend_ledger.read_jsonl(run_dir / "events.jsonl") + if cron_lock_conflict: + factory_scale_append_event(run_dir, "cron_lock_conflict", {"status": "skipped", "reason": "another factory-scale tick holds the flock lock"}) + if events and str(events[-1].get("event") or "") == "cron_lock_conflict": + factory_scale_append_event(run_dir, "hard_stop", {"reason": "cron lock conflict lasted more than one tick"}) + factory_scale_append_metrics(run_dir, {"tick_result": "cron_lock_conflict"}) + factory_scale_write_handoff(run_dir) + payload = factory_scale_status_payload(run_dir.name) + return payload, 0 + + if any(str(item.get("event") or "") == "hard_stop" for item in events): + factory_scale_append_metrics(run_dir, {"tick_result": "already_stopped"}) + factory_scale_write_handoff(run_dir) + payload = factory_scale_status_payload(run_dir.name) + return payload, 0 + + deadline = parse_iso_datetime(config.get("deadline_at")) + if deadline and datetime.now(timezone.utc) >= deadline: + factory_scale_append_event(run_dir, "deadline_reached", {"deadline_at": config.get("deadline_at"), "status": "stopped"}) + factory_scale_append_metrics(run_dir, {"tick_result": "deadline_reached"}) + factory_scale_write_handoff(run_dir) + payload = factory_scale_status_payload(run_dir.name) + return payload, 0 + + if factory_scale_consecutive_infra_failures(events) >= 2: + factory_scale_append_event(run_dir, "hard_stop", {"reason": "two consecutive infrastructure failures"}) + factory_scale_append_metrics(run_dir, {"tick_result": "hard_stop"}) + factory_scale_write_handoff(run_dir) + payload = factory_scale_status_payload(run_dir.name) + return payload, 0 + + execution = factory_scale_next_execution(run_dir) + if not execution: + already_recorded = any(str(item.get("event") or "") == "run_complete" for item in events) + if not already_recorded: + factory_scale_append_event(run_dir, "run_complete", {"status": "completed"}) + factory_scale_append_metrics(run_dir, {"tick_result": "run_complete"}) + factory_scale_write_handoff(run_dir) + payload = factory_scale_status_payload(run_dir.name) + return payload, 0 + + before_dirty = count_dirty_files() + factory_scale_append_event( + run_dir, + "tick_started", + {"execution_id": str(execution["id"]), "execution_index": int(execution.get("index") or 0), "dirty_count_before": before_dirty}, + ) + proreq_result = factory_scale_run_proreq_execution(run_dir, execution, config) + patch_summary: dict[str, Any] = {} + if proreq_result.get("status") == "completed" and bool(config.get("patch_swarm_enabled")): + milestone = factory_scale_milestone_for_execution(run_dir, execution) + if str(milestone.get("patch_swarm_trigger_after") or "") == str(execution.get("id") or "") and not factory_scale_patch_swarm_already_ran(run_dir, str(milestone.get("id") or "")): + patch_summary = factory_scale_run_patch_swarm_milestone(run_dir, milestone) + + after_dirty = count_dirty_files() + if after_dirty != before_dirty: + factory_scale_append_event( + run_dir, + "dirty_count_changed", + {"dirty_count_before": before_dirty, "dirty_count_after": after_dirty, "matched_ledger_event": True}, + ) + events_after = spend_ledger.read_jsonl(run_dir / "events.jsonl") + if factory_scale_consecutive_infra_failures(events_after) >= 2: + factory_scale_append_event(run_dir, "hard_stop", {"reason": "two consecutive infrastructure failures"}) + factory_scale_append_metrics( + run_dir, + { + "tick_result": proreq_result.get("status"), + "execution_id": str(execution["id"]), + "dirty_count_before": before_dirty, + "dirty_count_after": after_dirty, + "patch_swarm_status": patch_summary.get("status", "not_scheduled"), + "patch_swarm_candidate_count": patch_summary.get("candidate_count", 0), + }, + ) + factory_scale_write_handoff(run_dir) + payload = { + "status": factory_scale_status_payload(run_dir.name).get("status"), + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "execution": {"id": str(execution["id"]), "status": proreq_result.get("status"), "pipeline_root": proreq_result.get("pipeline_root")}, + "patch_swarm": patch_summary, + "summary": factory_scale_status_payload(run_dir.name), + } + return payload, 0 if proreq_result.get("status") == "completed" else 1 + + +def command_factory_scale_tick(args: argparse.Namespace) -> int: + if not args.run_id: + latest = latest_factory_scale_run_dir() + if not latest: + print(json.dumps({"status": "unknown", "reason": "no factory-scale run found"}, indent=2), file=sys.stderr) + return 2 + run_dir = latest + else: + run_dir = factory_scale_run_dir(args.run_id) + if not run_dir.exists(): + print(json.dumps({"status": "unknown", "reason": f"run not found: {run_dir}"}, indent=2), file=sys.stderr) + return 2 + requested_batch_size = max(1, int(getattr(args, "batch_size", 1))) + if bool(getattr(args, "cron_lock_conflict", False)): + requested_batch_size = 1 + ticks: list[dict[str, Any]] = [] + exit_code = 0 + for batch_index in range(1, requested_batch_size + 1): + payload, tick_exit = factory_scale_tick_once(run_dir, cron_lock_conflict=bool(getattr(args, "cron_lock_conflict", False))) + payload["batch_index"] = batch_index + ticks.append(payload) + if tick_exit != 0: + exit_code = tick_exit + break + summary = as_dict(payload.get("summary")) if isinstance(payload.get("summary"), dict) else payload + status = str(summary.get("status") or payload.get("status") or "") + if status in {"completed", "deadline_reached", "stopped"} or not str(summary.get("next_execution_id") or ""): + break + summary = factory_scale_status_payload(run_dir.name) + if requested_batch_size == 1: + output = ticks[-1] if ticks else summary + else: + output = { + "schema_version": "cento.walk_autopilot.factory_scale.batch_tick.v1", + "status": summary.get("status"), + "run_id": run_dir.name, + "run_dir": rel(run_dir), + "batch_size_requested": requested_batch_size, + "batch_size_completed": len(ticks), + "ticks": ticks, + "summary": summary, + } + if args.json: + print(json.dumps(output, indent=2, sort_keys=True)) + else: + print(output.get("status", "unknown")) + return exit_code + + +def command_factory_scale_status(args: argparse.Namespace) -> int: + payload = factory_scale_status_payload(args.run_id, args.crontab_file) + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload.get("status", "unknown")) + return 0 + + +def command_factory_scale_install_cron(args: argparse.Namespace) -> int: + payload = factory_scale_install_cron_for_run( + args.run_id, + duration_hours=float(args.duration_hours), + crontab_file=args.crontab_file, + dry_run=bool(args.dry_run), + ) + if factory_scale_run_dir(args.run_id).exists(): + factory_scale_write_handoff(factory_scale_run_dir(args.run_id)) + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload["status"]) + return 0 + + +def command_factory_scale_uninstall_cron(args: argparse.Namespace) -> int: + current = read_crontab(args.crontab_file) + updated = strip_factory_scale_cron_block(current) + if not args.dry_run: + write_crontab(updated, args.crontab_file) + payload = { + "schema_version": "cento.walk_autopilot.factory_scale.cron_uninstall.v1", + "status": "planned" if args.dry_run else "uninstalled", + "dry_run": bool(args.dry_run), + "cron_installed_before": FACTORY_SCALE_CRON_BEGIN in current, + "crontab_file": args.crontab_file, + } + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else payload["status"]) + return 0 + + +def add_common_run_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--run-id", default="") + parser.add_argument("--loops", type=int, default=12) + parser.add_argument("--cadence-seconds", type=int, default=20 * 60) + parser.add_argument("--soft-cap-usd", type=float, default=12.0) + parser.add_argument("--hard-cap-usd", type=float, default=20.0) + parser.add_argument("--max-worker-launch", type=int, default=3) + parser.add_argument("--live-workers", action="store_true") + parser.add_argument("--allow-live-api", action="store_true") + parser.add_argument( + "--review-unblock-mode", + choices=("report", "aggressive"), + default="", + help="Override Review/Unblock stage mode. Default is report unless --live-workers is enabled.", + ) + parser.add_argument("--no-review-unblock", action="store_true", help="Skip the Review/Unblock Autopilot stage.") + parser.add_argument( + "--dashboard-total-spend-usd", + type=float, + default=None, + help=f"OpenAI dashboard total spend snapshot. Required for --allow-live-api; {DASHBOARD_TOTAL_ENV} is also accepted.", + ) + parser.add_argument("--notify-target", default="") + parser.add_argument("--patch-swarm", action="store_true", help="Run one fixture Patch Swarm e2e inside each loop.") + parser.add_argument("--patch-swarm-candidate-target", type=int, default=100) + parser.add_argument("--patch-swarm-max-parallel-agents", type=int, default=5) + parser.add_argument("--patch-swarm-providers", default="codex-exec,claude-code,api-openai") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run append-only Walk Autopilot loops.") + sub = parser.add_subparsers(dest="command", required=True) + + run = sub.add_parser("run", help="Run loops in the foreground.") + add_common_run_args(run) + run.add_argument("--factory-run-id", default="walk-autopilot-followup") + run.add_argument("--command-timeout", type=int, default=180) + run.add_argument("--make-check-timeout", type=int, default=900) + run.add_argument("--make-check-every", type=int, default=2) + run.add_argument("--proreq-timeout", type=int, default=900) + run.add_argument("--dashboard-delta-usd", type=float, default=0.0) + run.set_defaults(func=command_run) + + tmux_cmd = sub.add_parser("start-tmux", help="Start the loop in a detached tmux session.") + add_common_run_args(tmux_cmd) + tmux_cmd.add_argument("--session", default="") + tmux_cmd.set_defaults(func=command_start_tmux) + + status = sub.add_parser("status", help="Show latest or named walk autopilot status.") + status.add_argument("--run-id", default="") + status.set_defaults(func=command_status) + + review_unblock = sub.add_parser("review-unblock", help="Scan Agent Work review, blocked, and stale states and choose safe recovery actions.") + review_unblock_sub = review_unblock.add_subparsers(dest="review_unblock_command", required=True) + + review_unblock_run = review_unblock_sub.add_parser("run", help="Run one Review/Unblock decision pass.") + review_unblock_run.add_argument("--run-id", default="") + review_unblock_run.add_argument("--mode", choices=("report", "aggressive"), default="report") + review_unblock_run.add_argument("--json", action="store_true") + review_unblock_run.set_defaults(func=command_review_unblock_run) + + review_unblock_status = review_unblock_sub.add_parser("status", help="Show latest Review/Unblock run status.") + review_unblock_status.add_argument("--json", action="store_true") + review_unblock_status.set_defaults(func=command_review_unblock_status) + + patch_swarm = sub.add_parser("patch-swarm", help="Coordinate Patch Swarm dry-run e2e through Walk Autopilot artifacts.") + patch_swarm_sub = patch_swarm.add_subparsers(dest="patch_swarm_command", required=True) + + patch_swarm_run = patch_swarm_sub.add_parser("run", help="Run one fixture Patch Swarm e2e and write autopilot summary artifacts.") + patch_swarm_run.add_argument("--run-id", default="") + patch_swarm_run.add_argument("--candidate-target", type=int, default=100) + patch_swarm_run.add_argument("--max-parallel-agents", type=int, default=5) + patch_swarm_run.add_argument("--providers", default="codex-exec,claude-code,api-openai") + patch_swarm_run.add_argument("--timeout", type=int, default=360) + patch_swarm_run.add_argument("--json", action="store_true") + patch_swarm_run.set_defaults(func=command_patch_swarm_run) + + patch_swarm_status = patch_swarm_sub.add_parser("status", help="Show latest Patch Swarm autopilot summary.") + patch_swarm_status.add_argument("--run-id", default="") + patch_swarm_status.add_argument("--json", action="store_true") + patch_swarm_status.set_defaults(func=command_patch_swarm_status) + + factory_scale = sub.add_parser("factory-scale", help="Run the six-hour Factory scale final test coordinator.") + factory_scale_sub = factory_scale.add_subparsers(dest="factory_scale_command", required=True) + + factory_scale_start = factory_scale_sub.add_parser("start", help="Initialize and schedule the Factory scale final test run.") + factory_scale_start.add_argument("--run-id", default="") + factory_scale_start.add_argument("--duration-hours", type=float, default=6.0) + factory_scale_start.add_argument("--proreq-executions", type=int, default=30) + factory_scale_start.add_argument("--min-proreq-calls", type=int, default=100) + factory_scale_start.add_argument("--patch-swarm", action="store_true") + factory_scale_start.add_argument("--execute-proreq", action="store_true", help="Run ProReq-light commands instead of ledgering the API-safe command calls.") + factory_scale_start.add_argument("--proreq-command-timeout", type=int, default=900) + factory_scale_start.add_argument("--crontab-file", default=os.environ.get("CENTO_FACTORY_SCALE_CRONTAB_PATH", "")) + factory_scale_start.add_argument("--no-install-cron", action="store_true", help="Initialize artifacts without installing the managed cron block.") + factory_scale_start.add_argument("--dry-run", action="store_true", help="Plan cron installation without writing crontab.") + factory_scale_start.add_argument("--json", action="store_true") + factory_scale_start.set_defaults(func=command_factory_scale_start) + + factory_scale_start_day = factory_scale_sub.add_parser("start-day", help="Initialize and schedule the day-scale Factory autopilot run.") + factory_scale_start_day.add_argument("--run-id", default="") + factory_scale_start_day.add_argument("--target-proreq-calls", type=int, default=3000) + factory_scale_start_day.add_argument("--max-proreq-calls", type=int, default=10000) + factory_scale_start_day.add_argument("--duration-hours", type=float, default=12.0) + factory_scale_start_day.add_argument("--tick-minutes", type=int, default=10) + factory_scale_start_day.add_argument("--batch-size", type=int, default=5) + factory_scale_start_day.add_argument("--patch-swarm-candidate-target", type=int, default=100) + factory_scale_start_day.add_argument("--patch-swarm-max-parallel-agents", type=int, default=5) + factory_scale_start_day.add_argument("--no-patch-swarm", action="store_true", help="Disable Patch Swarm fixture milestones for the day-scale run.") + factory_scale_start_day.add_argument("--proreq-command-timeout", type=int, default=900) + factory_scale_start_day.add_argument("--crontab-file", default=os.environ.get("CENTO_FACTORY_SCALE_CRONTAB_PATH", "")) + factory_scale_start_day.add_argument("--no-install-cron", action="store_true", help="Initialize day-scale artifacts without installing the managed cron block.") + factory_scale_start_day.add_argument("--dry-run", action="store_true", help="Plan cron installation without writing crontab.") + factory_scale_start_day.add_argument("--json", action="store_true") + factory_scale_start_day.set_defaults(func=command_factory_scale_start_day) + + factory_scale_preflight = factory_scale_sub.add_parser("preflight", help="Check for active Factory scale lanes before starting or advancing.") + factory_scale_preflight.add_argument("--run-id", default="") + factory_scale_preflight.add_argument("--crontab-file", default=os.environ.get("CENTO_FACTORY_SCALE_CRONTAB_PATH", "")) + factory_scale_preflight.add_argument("--json", action="store_true") + factory_scale_preflight.set_defaults(func=command_factory_scale_preflight) + + factory_scale_advance = factory_scale_sub.add_parser("advance", help="Index completed Factory scale receipts and write Safe Integrator promotion artifacts.") + factory_scale_advance.add_argument("--run-id", default="") + factory_scale_advance.add_argument("--promotion-limit", type=int, default=25) + factory_scale_advance.add_argument("--allow-incomplete", action="store_true") + factory_scale_advance.add_argument("--allow-live-api", action="store_true") + factory_scale_advance.add_argument("--dashboard-total-spend-usd", type=float, default=None) + factory_scale_advance.add_argument("--hard-cap-usd", type=float, default=25.0) + factory_scale_advance.add_argument("--max-live-calls-per-hour", type=int, default=4) + factory_scale_advance.add_argument("--min-live-call-spacing-seconds", type=int, default=900) + factory_scale_advance.add_argument("--crontab-file", default=os.environ.get("CENTO_FACTORY_SCALE_CRONTAB_PATH", "")) + factory_scale_advance.add_argument("--json", action="store_true") + factory_scale_advance.set_defaults(func=command_factory_scale_advance) + + factory_scale_promote = factory_scale_sub.add_parser("promote", help="Promote factory-scale advance candidates into a Factory validation run.") + factory_scale_promote.add_argument("--run-id", default="") + factory_scale_promote.add_argument("--promotion-plan", default="") + factory_scale_promote.add_argument("--factory-run", default="") + factory_scale_promote.add_argument("--limit", type=int, default=25) + factory_scale_promote.add_argument("--exclusive-paths", action="store_true", default=True, help="Skip candidates that touch paths already selected in this promotion batch.") + factory_scale_promote.add_argument("--allow-path-overlap", dest="exclusive_paths", action="store_false", help="Allow Factory to reject overlapping owned paths instead of filtering them first.") + factory_scale_promote.add_argument("--apply", action="store_true", help="Apply through Factory/Safe Integrator worktree after fanout validation.") + factory_scale_promote.add_argument("--validate-each", action="store_true", help="Run candidate validation after each Safe Integrator apply.") + factory_scale_promote.add_argument("--branch", default="") + factory_scale_promote.add_argument("--worktree", default="") + factory_scale_promote.add_argument("--allow-incomplete", action="store_true") + factory_scale_promote.add_argument("--crontab-file", default=os.environ.get("CENTO_FACTORY_SCALE_CRONTAB_PATH", "")) + factory_scale_promote.add_argument("--json", action="store_true") + factory_scale_promote.set_defaults(func=command_factory_scale_promote) + + factory_scale_tick = factory_scale_sub.add_parser("tick", help="Run one Factory scale tick.") + factory_scale_tick.add_argument("--run-id", default="") + factory_scale_tick.add_argument("--batch-size", type=int, default=1) + factory_scale_tick.add_argument("--cron-lock-conflict", action="store_true", help=argparse.SUPPRESS) + factory_scale_tick.add_argument("--json", action="store_true") + factory_scale_tick.set_defaults(func=command_factory_scale_tick) + + factory_scale_status = factory_scale_sub.add_parser("status", help="Show log-derived Factory scale final test status.") + factory_scale_status.add_argument("--run-id", default="") + factory_scale_status.add_argument("--crontab-file", default=os.environ.get("CENTO_FACTORY_SCALE_CRONTAB_PATH", "")) + factory_scale_status.add_argument("--json", action="store_true") + factory_scale_status.set_defaults(func=command_factory_scale_status) + + factory_scale_install = factory_scale_sub.add_parser("install-cron", help="Install the marked Factory scale cron block.") + factory_scale_install.add_argument("--run-id", required=True) + factory_scale_install.add_argument("--duration-hours", type=float, default=6.0) + factory_scale_install.add_argument("--crontab-file", default=os.environ.get("CENTO_FACTORY_SCALE_CRONTAB_PATH", "")) + factory_scale_install.add_argument("--dry-run", action="store_true") + factory_scale_install.add_argument("--json", action="store_true") + factory_scale_install.set_defaults(func=command_factory_scale_install_cron) + + factory_scale_uninstall = factory_scale_sub.add_parser("uninstall-cron", help="Remove the marked Factory scale cron block.") + factory_scale_uninstall.add_argument("--crontab-file", default=os.environ.get("CENTO_FACTORY_SCALE_CRONTAB_PATH", "")) + factory_scale_uninstall.add_argument("--dry-run", action="store_true") + factory_scale_uninstall.add_argument("--json", action="store_true") + factory_scale_uninstall.set_defaults(func=command_factory_scale_uninstall_cron) + + routing = sub.add_parser("routing", help="Run and manage the lightweight routing nativeness loop.") + routing_sub = routing.add_subparsers(dest="routing_command", required=True) + + routing_run = routing_sub.add_parser("run", help="Collect counts-only routing stats, decide changes, and hand off bounded work.") + routing_run.add_argument("--run-id", default="") + routing_run.add_argument("--crontab-file", default=os.environ.get("CENTO_ROUTING_CRONTAB_PATH", "")) + routing_run.add_argument("--no-agent-work", action="store_true", help="Write reports without creating or updating Agent Work.") + routing_run.add_argument("--json", action="store_true") + routing_run.set_defaults(func=command_routing_run) + + routing_status = routing_sub.add_parser("status", help="Show latest routing nativeness run and cron status.") + routing_status.add_argument("--crontab-file", default=os.environ.get("CENTO_ROUTING_CRONTAB_PATH", "")) + routing_status.add_argument("--json", action="store_true") + routing_status.set_defaults(func=command_routing_status) + + routing_install = routing_sub.add_parser("install-cron", help="Install the marked routing nativeness cron block.") + routing_install.add_argument("--every-hours", type=int, default=4) + routing_install.add_argument("--crontab-file", default=os.environ.get("CENTO_ROUTING_CRONTAB_PATH", "")) + routing_install.add_argument("--dry-run", action="store_true") + routing_install.add_argument("--json", action="store_true") + routing_install.set_defaults(func=command_routing_install_cron) + + routing_uninstall = routing_sub.add_parser("uninstall-cron", help="Remove the marked routing nativeness cron block.") + routing_uninstall.add_argument("--crontab-file", default=os.environ.get("CENTO_ROUTING_CRONTAB_PATH", "")) + routing_uninstall.add_argument("--dry-run", action="store_true") + routing_uninstall.add_argument("--json", action="store_true") + routing_uninstall.set_defaults(func=command_routing_uninstall_cron) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write_cento_secret_env.py b/scripts/write_cento_secret_env.py new file mode 100644 index 0000000..635ca09 --- /dev/null +++ b/scripts/write_cento_secret_env.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import shlex +import stat +import sys +from pathlib import Path + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("usage: write_cento_secret_env.py PATH", file=sys.stderr) + return 2 + raw = sys.stdin.buffer.read() + try: + api_key_raw, model_raw = raw.split(b"\0", 1) + except ValueError: + print("expected NUL-separated key and model on stdin", file=sys.stderr) + return 2 + api_key = api_key_raw.decode("utf-8") + model = model_raw.decode("utf-8") + if not api_key: + print("OPENAI_API_KEY is required", file=sys.stderr) + return 2 + if not model: + print("CENTO_OPENAI_WORKER_MODEL is required", file=sys.stderr) + return 2 + + path = Path(argv[1]).expanduser() + path.parent.mkdir(parents=True, mode=0o700, exist_ok=True) + try: + path.parent.chmod(0o700) + except OSError: + pass + + existing: list[str] = [] + if path.exists(): + try: + existing = path.read_text(encoding="utf-8").splitlines() + except OSError: + existing = [] + + managed = {"OPENAI_API_KEY", "CENTO_OPENAI_WORKER_MODEL"} + kept: list[str] = [] + for line in existing: + stripped = line.strip() + probe = stripped[7:].lstrip() if stripped.startswith("export ") else stripped + key = probe.split("=", 1)[0].strip() if "=" in probe else "" + if key in managed: + continue + kept.append(line) + + if kept and kept[-1].strip(): + kept.append("") + kept.extend( + [ + "# Managed by Cento local secret setup.", + f"export OPENAI_API_KEY={shlex.quote(api_key)}", + f"export CENTO_OPENAI_WORKER_MODEL={shlex.quote(model)}", + ] + ) + + tmp = path.with_name(f".{path.name}.tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write("\n".join(kept).rstrip() + "\n") + os.replace(tmp, path) + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/skills/claude-code/cento-native.md b/skills/claude-code/cento-native.md new file mode 100644 index 0000000..bbbdf0d --- /dev/null +++ b/skills/claude-code/cento-native.md @@ -0,0 +1,83 @@ +# Claude Code: Cento Native + +Use this whenever work touches Cento tools, Taskstream/agent-work, MCP, cluster/bridge, mobile/iPhone, skills, or command routing. + +## Core Contract + +Treat Cento as the source of truth. Before creating scripts, registry entries, one-off commands, or new workflows, discover the existing Cento surface: + +```bash +cento gather-context --no-remote +cento tools +cento aliases +cento platforms +``` + +Use full `cento gather-context` when Linux/macOS reachability matters. Use `data/tools.json`, `cento tools`, `cento docs`, and platform reports as the live command contract. + +## Route Before Editing + +Prefer existing paths in this order: + +1. Repo-local Cento MCP tools for board, story, cluster, and bridge operations. +2. Registered CLI tools from `cento tools`. +3. Existing aliases from `cento aliases`. +4. Existing scripts under `scripts/`, after confirming the registered entrypoint. +5. New code or registry entries only when no existing route fits. + +For the Cento temp clipboard bridge, the only supported operator command is: + +```bash +cento temp run +``` + +To change what it copies, edit only the `COPY_FILE` line in `scripts/cento_temp.sh`. Do not add ids, flags, list/show/add/remove, secret prompts, cross-node routing, or fallback chains to `cento temp`. + +For other temporary or one-off shell work, do not add a permanent registered tool by default. Use: + +```bash +cento cluster exec macos -- '...' +cento cluster exec linux -- '...' +cento bridge to-mac -- '...' +cento bridge to-linux -- '...' +cento batch-exec --root DIR --pattern GLOB --command '...' +``` + +Use `batch-exec` for one command over directories. Use `cluster exec` or `bridge to-*` for lower-level node-targeted temporary commands. If clipboard transport breaks, fix the local `pbcopy` shim instead of expanding `cento temp`. + +## Tasking + +For Cento feature or behavior changes, create or identify agent-work unless the user explicitly says not to: + +```bash +cento agent-work create --title "..." --description "..." --node macos --role builder --package agent-ops --json +``` + +If local tasking fails, try Linux: + +```bash +cento cluster exec linux -- 'cd /home/alice/projects/cento && ./scripts/cento.sh agent-work create --title "..." --description "..." --node macos --role builder --package agent-ops --json' +``` + +If both task backends are unavailable, state the blocker and continue only if immediate action is needed. + +## Safety + +- Keep `.env.mcp` machine-local. +- Do not overwrite unrelated dirty work. +- Do not run platform-specific tools on the wrong node. +- Do not reset pairing, signing, launchd, tmux, Docker, bridge, or git state without explaining why. +- If the user says to reuse an existing Cento tool, scan before proposing implementation. + +## Validation + +Use focused checks: + +```bash +python3 -m json.tool data/tools.json >/tmp/cento-tools-json-check.txt +cento tools +cento platforms macos +make check +``` + +For cross-node behavior, validate with `cento cluster status`, `cento bridge mesh-status`, or the exact `cluster exec` path used. diff --git a/skills/codex/app-overview-page-v1/SKILL.md b/skills/codex/app-overview-page-v1/SKILL.md new file mode 100644 index 0000000..3acf005 --- /dev/null +++ b/skills/codex/app-overview-page-v1/SKILL.md @@ -0,0 +1,127 @@ +--- +name: app_overview_page_v1 +description: Use when creating or refactoring a Cento app documentation page into a Product Control Surface with mandatory docs, dashboard, status, operations, architecture, and entry-point sections. +--- + +# App Overview Page v1 + +Create a single source-of-truth page for an app that combines docs, dashboard, status, operations, and entry points. + +## Required Page Contract + +Every app overview page must include these sections, in this order: + +1. Header +2. Control Strip +3. Project Dashboard +4. About +5. Current Release +6. System Architecture +7. Operations +8. Links + Entry Points + +Do not skip sections. If a system does not have a production value yet, state the real local/dev value and why. + +## Section Requirements + +### Header + +Include: + +- app name +- one-line functional description +- status badge: `development`, `staging`, or `production` +- version +- last updated date + +### Control Strip + +Include clickable, real links: + +- live app URL +- repository URL +- Cento Dashboard or Taskstream entry + +Optional links are allowed only when real: API endpoint, preview build, feature flags panel. + +### Project Dashboard + +Include: + +- status +- version +- environment +- last deploy, validation, or preview refresh time + +Prefer product-specific metrics over fake business metrics. Use realistic values and avoid placeholder rows. + +### About + +Use a short operational paragraph, then feature bullets. Answer what the system does every day. + +### Current Release + +Include: + +- version +- build number +- release date +- release notes + +Release notes must reflect actual capabilities. Do not add aspirational features. + +### System Architecture + +Use a simple readable pipeline, for example: + +```text +PWA Preview -> Stroke Player -> Kanji Dataset -> Local Storage +``` + +Include components, data flow, storage type, and analytics or validation path when applicable. + +### Operations + +Include agent-executable actions such as: + +- open in Taskstream +- open preview or build target +- view logs or evidence +- run validation + +Actions can link to the relevant route, issue, artifact, or docs anchor. Do not include nonfunctional buttons. + +### Links + Entry Points + +Include real anchors or links for: + +- User Guide +- Data Model +- Changelog +- API, when one exists + +## Design Rules + +- Use a two-column or three-column operational grid on desktop: core content left, actions and links right. +- Stack cleanly on mobile. +- Every section is a dense information card. +- Use dark surfaces, subtle borders, and orange only for primary accents/actions. +- Avoid marketing hero sections, filler copy, and empty space. + +## AI Generation Rules + +Must: + +- fill every section +- reflect actual system behavior +- use realistic values +- keep links clickable and real +- validate visually at desktop and mobile widths + +Must not: + +- say `TBD` +- say `coming soon` +- invent fake features not present in the release notes +- use placeholder metrics without explanation +- bury operations below marketing content diff --git a/skills/codex/cento-native/SKILL.md b/skills/codex/cento-native/SKILL.md new file mode 100644 index 0000000..a43e9eb --- /dev/null +++ b/skills/codex/cento-native/SKILL.md @@ -0,0 +1,213 @@ +--- +name: cento-native +description: "Use when working with Cento as a native automation platform: discovering existing Cento tools before creating commands, routing user intent to registered tools, using temp/one-off command paths, deciding when agent-work is required, operating across Mac/Linux/iPhone nodes, or changing Cento skills, MCP, Taskstream, cluster, mobile, or command behavior." +--- + +# Cento Native + +Treat Cento as the source of truth. Before inventing scripts, tools, registry entries, workflows, or cross-node commands, ask Cento what already exists and route through that surface. + +## First Moves + +From a Cento checkout, start with: + +```bash +cento gather-context --no-remote +cento tools +``` + +Use full `cento gather-context` when Linux reachability matters. Treat `data/tools.json`, `cento tools`, `cento platforms`, and `cento docs` as the live command contract. + +If the task is about one tool or command family, inspect it before editing: + +```bash +cento docs TOOL_OR_BUILTIN +cento platforms macos +cento platforms linux +rg -n '"id": "TOOL_ID"|TOOL_ID|subcommand|usage' data scripts docs +``` + +## Self-Improvement Log + +For each major Cento self-improvement step, check and maintain the append-only log at `docs/ai-self-improvement-log.md`. + +- Before planning or implementing self-improvement work, read the latest relevant records when the change affects routing, autonomy, pipeline behavior, skill behavior, observability, validation, agent-work, or operator workflow. +- Compare the current request against prior records so Cento does not repeat unclear or already-completed loops without naming what is different now. +- At the end of each major step, append a new record using the schema in that doc. +- Record what changed, what worked, what did not work, validation/evidence, next steps, suggestions, and tags. +- Treat the log as append-only. Do not rewrite prior records; add corrections or follow-ups as new records unless a security redaction is required. + +## Intent Recognition + +Before choosing a Cento surface, infer the operator's intent from the whole message, recent context, attachments, and requested side effects. Use that intent to decide whether the work is read-only analysis, human-facing documentation, command-reference documentation, implementation, tasking, one-off execution, pipeline planning, cross-node work, or evidence/validation. + +Prefer these defaults unless the user says otherwise: + +- "somewhere in Docs", "save in Docs", or "human Docs" means human-facing Cento docs: readable files under `docs/`, Cento Console `/docs` links, and `docs/nav.html` when discoverability matters. `cento docs`, `data/cento-cli.json`, and `docs/tool-index.md` are command-reference surfaces, not the whole Docs intent. +- "within cento" means the Cento checkout and registered Cento surfaces, not an ad hoc home-directory artifact. +- "analysis", "summary", "suggestions", "check if", or "calculate" means read-only work unless the user later asks to implement or save the output. +- "create plan" means write an actionable plan artifact when the user asks to save it; "Implement the plan" means execute the latest accepted plan instead of stopping at another proposal. +- "e2e", "coordinate until done", or "until done" means carry implementation through validation and evidence, and create/use agent-work only when durable task coordination is part of the request. +- "use OCI CLI", "bucket", "namespace", or "Object Storage" means route through the registered `object-storage` surface before adding cloud-specific code. + +If multiple interpretations fit, choose the lowest side-effect path that still satisfies the user. Ask only when the wrong intent would cause spend, broad dispatch, public exposure, destructive changes, or a materially different deliverable. + +## Short Tools Summary + +This summary is populated from `data/tools.json` / `cento tools` as a quick routing aid. Treat the live registry and `cento docs TOOL` as the source of truth; this section can lag or disagree briefly when registry, generated docs, and installed skill copies drift. + +| Tool | Use | +|---|---| +| `agent-pool-kick` | Keep bounded builder, validator, small-task, and coordinator lanes moving. | +| `agent-processes` | Inspect cluster-wide managed and manual agent sessions. | +| `agent-work` | Track, assign, dispatch, validate, and review Taskstream-backed agent work. | +| `agent-work-hygiene` | Reconcile agent ledgers, tmux sessions, and Codex/Claude processes. | +| `audio-quick-connect` | Connect paired Bluetooth audio devices on Linux. | +| `batch-exec` | Run one shell command across many directories. | +| `bluetooth-audio-doctor` | Diagnose and repair Bluetooth audio failures. | +| `bridge` | Manage OCI reverse SSH bridge and cross-node command routes. | +| `build` | Run manifest-owned local build worker and patch integration flows. | +| `burp` | Download, set up, and control Burp Suite Community. | +| `cento-cli` | Use the main Cento facade, built-ins, docs, tools, aliases, and completion. | +| `cento-mcp` | Expose safe Cento context, agent-work, story, cluster, and bridge MCP tools. | +| `cluster` | Manage nodes, cluster status, remote execution, bridge healing, and git drift. | +| `crm` | Run the embedded career CRM module and local SPA. | +| `daily` | Open the execution cockpit for daily planning and continuity. | +| `dashboard` | Serve the local Cento web dashboard. | +| `demo-evidence` | Record 10-30 second demo videos with receipts for Factory and worker evidence. | +| `discord` | Update, rerun, and inspect Discord on Linux. | +| `display-layout-fix` | Stack two monitors vertically and refresh wallpaper/polybar. | +| `factory` | Plan, queue, dispatch, collect, validate, integrate, and release multi-task runs. | +| `foundry` | Create Cento-native business tools through Factory, Workset, train promotion, storage policy, cost receipts, and demo evidence. | +| `gather-context` | Gather AI-ready local and remote Cento context. | +| `i3reorg` | Reorganize i3 workspaces across preferred monitor layout. | +| `incident` | Run bounded incident checks and guarded escalation. | +| `install-linux` | Install Linux Cento dependencies and shell integration. | +| `install-macos` | Install macOS Cento dependencies and shell integration. | +| `kitty-theme-manager` | Manage Kitty themes and tmux-aware refresh. | +| `mcp` | Manage repo-root MCP config, templates, validation, and docs. | +| `mobile` | Run native iOS/PWA helper and e2e validation commands. | +| `mozilla-vpn` | Control Mozilla VPN from the Linux desktop pane. | +| `network-tui` | Monitor cluster connection, node, tmux, and companion-device state. | +| `notify` | Send ntfy notifications to configured targets. | +| `object-storage` | Upload a run-scoped dummy file to OCI Object Storage and record receipts. | +| `opencode` | Launch the managed opencode fork wrapper. | +| `parallel-delivery` | Coordinate Hard ProReq fanout, Workset manifests, and validation/demo receipts. | +| `platform-report` | Report platform support and generate the support matrix. | +| `preset` | Apply desktop presets such as Industrial OS. | +| `project-scaffold` | Scaffold a generic project workspace. | +| `quick-help` | Open the Linux rofi help palette. | +| `quick-help-fzf` | Open the cross-platform fzf command palette. | +| `rd` | Compatibility shortcut for `cento discord rerun`. | +| `repo-snapshot` | Write a compact repo tree/status/diff/commit report. | +| `runtime` | Inspect and validate builder runtime profiles. | +| `scan` | Generate an archived HTML one-pager for a repo topic. | +| `search-report` | Search a tree and write a Markdown report. | +| `system-inventory` | Capture host, shell, tool, PATH, and repo baseline. | +| `temp` | Register short-lived operator commands and captured outputs. | +| `tool-index` | Generate `docs/tool-index.md` from the registry. | +| `tui` | Open the Telegram TUI. | +| `wallpaper-manager` | Choose, preview, apply, and persist i3/feh wallpapers. | +| `workset` | Run exclusive-path N-worker delivery with sequential integration. | + +For Factory or Codex worker visual evidence, prefer: + +```bash +cento demo-evidence record --factory-run workspace/runs/factory/ --task --worker --duration 15 +``` + +## Routing Rules + +Prefer existing paths in this order: + +1. MCP tools, when a structured Cento MCP surface exists for the operation. +2. Registered CLI tools from `cento tools` / `data/tools.json`. +3. Existing aliases from `cento aliases`. +4. Existing scripts under `scripts/` only after confirming they are the registered entrypoint or intended backend. +5. New code or registry entries only when discovery shows no existing path fits. + +For the Cento temp clipboard bridge, never create a temp command registry entry. The only supported operator command is: + +```bash +cento temp run +``` + +To change what it copies, edit only the `COPY_FILE` line in `scripts/cento_temp.sh`. Do not add ids, flags, list/show/add/remove, secret prompts, cross-node routing, or fallback chains to `cento temp`. + +For other one-off or temporary shell work, do not add a registered tool by default. Use the existing one-off command surface: + +```bash +cento cluster exec macos -- '...' +cento cluster exec linux -- '...' +cento bridge to-mac -- '...' +cento bridge to-linux -- '...' +cento batch-exec --root DIR --pattern GLOB --command '...' +``` + +Use `batch-exec` for "run this shell command over directories". Use `cluster exec` or `bridge to-*` for node-targeted temporary commands. Register a new tool only when the command is durable, user-facing, documented, and not covered by existing Cento routing. + +## Choose Correct Pipeline + +Most Cento requests should route through an existing pipeline instead of a new script or ad hoc implementation. If a specific pipeline, tool, or command is named, use that surface. If the request is ambiguous, pick the lowest-compute pipeline that satisfies the intent and only ask the user when the choice changes side effects, spend, or scope in a way that cannot be inferred. + +Default compute order: + +1. Read-only inspection, dry-run, or prompt/artifact generation. +2. Deterministic local pipeline fallback with no live model calls. +3. Fixture or local-command execution. +4. API worker execution with explicit budget caps. +5. Pro, image generation, broad worker fanout, or live dispatch. + +Pipeline routing defaults: + +- Use `hard-proreq` / Dev Pipeline Studio Hard ProReq when the user asks for "proreq", "requirements", "manifest", "roadmap from my vision", "integration/validation guidance", or "ask ChatGPT Pro". Treat this as a prompt-and-artifact pipeline by default: parse the operator text into a ChatGPT image prompt for the screenshot lane and a ChatGPT Pro prompt/request for story manifests, integration manifests, validation manifests, and implementation guidance. Do not convert it directly into a hand-written doc unless the user explicitly asks for a doc output. +- Use `parallel-pipeline` or `cento workset` when the user asks to run parallel workers, execute code delivery, split work across exclusive write paths, or produce patch/artifact outputs from multiple workers. +- Use `factory` when the request is a durable multi-task project needing intake, queueing, leases, dispatch plans, patch collection, Safe Integrator state, release packets, or Taskstream synchronization. +- Use `scan` when the user asks for a one-page explanation of an existing repo topic or wants a codebase map without changing project state. +- Use `agent-work` when the user wants persistent task assignment, Taskstream issues, worker ownership, or human-visible operational tracking. + +For ProReq requests, only enable live Pro/image/API dispatch when the user asks for it, the relevant environment is configured, or the named pipeline contract requires it. Otherwise generate the request artifacts and evidence through the no-model or lowest-compute path and clearly record that the live call was skipped. + +## Tasking + +For Cento feature requests, UI changes, automation changes, MCP changes, Taskstream/agent-work, cluster behavior, mobile/iPhone behavior, Agent Processes/TUI, or command behavior changes: + +```bash +cento agent-work create --title "..." --description "..." --node macos --role builder --package agent-ops --json +``` + +If local tasking fails, try the Linux path: + +```bash +cento cluster exec linux -- 'cd /home/alice/projects/cento && ./scripts/cento.sh agent-work create --title "..." --description "..." --node macos --role builder --package agent-ops --json' +``` + +If both task backends are unavailable, state that clearly and continue only if the user needs immediate action. Do not block pure status checks, explanations, log reads, restarts, or explicitly taskless requests on agent-work. + +Choose packages conservatively: `agent-ops` for skills/routing/dispatch, `taskstream` for agent-work UI/workflow, `cluster` for bridge/mesh/node behavior, `iphone-cento` for mobile/iPhone, `mobile` for app-specific mobile work. + +## Safety Rules + +- Never copy `.env.mcp` or secrets between nodes. +- Never overwrite unrelated dirty work. Read `git status --short` and scope edits tightly. +- Do not run Linux-only tools on macOS or macOS-only tools on Linux unless changing platform support. +- Do not reset pairing, tunnel, signing, launchd, tmux, Docker, or git state without explaining why first. +- If a user asks for the temp clipboard bridge, keep the route at `cento temp run`; do not create named temp entries or ID-based run variants. +- If the user says "reuse existing Cento tool", scan `cento tools`, `cento aliases`, `data/tools.json`, and matching scripts before proposing edits. + +## Validation + +Use the narrowest reliable validation: + +```bash +python3 -m json.tool data/tools.json >/tmp/cento-tools-json-check.txt +cento tools +cento platforms macos +make check +``` + +For shell wrappers, run `--help` or a dry-run path. For registry changes, verify the tool appears in `cento tools` and platform reports. For cross-node behavior, validate through `cento cluster status`, `cento bridge mesh-status`, or the specific `cluster exec` path used. + +## References + +Read [references/routing.md](references/routing.md) when deciding how to map an ambiguous user request to an existing Cento command, especially temporary command requests. diff --git a/skills/codex/cento-native/references/routing.md b/skills/codex/cento-native/references/routing.md new file mode 100644 index 0000000..3b8caed --- /dev/null +++ b/skills/codex/cento-native/references/routing.md @@ -0,0 +1,80 @@ +# Cento Routing Reference + +Use this when the user asks for a Cento command, temporary automation, cross-node operation, or AI-native Cento workflow. + +## Discovery Checklist + +Run the smallest set that answers the routing question: + +```bash +cento gather-context --no-remote +cento tools +cento aliases +cento platforms +cento docs TOOL +rg -n 'KEYWORD|TOOL_ID|subcommand|usage' data scripts docs --glob '!docs/nav.html' --glob '!workspace/**' --glob '!logs/**' +``` + +If the user references "registered tools", inspect `data/tools.json`. If they reference the temp clipboard bridge, route only to `cento temp run`. For other one-off shell work, use `cluster`, `bridge`, or `batch-exec`. + +## Intent Defaults + +Classify intent before choosing a route: + +- Human docs: "somewhere in Docs", "save in Docs", "human Docs", or a manager/operator handoff. Use readable `docs/` pages, Cento Console `/docs`, and `docs/nav.html` where discoverability matters. +- Command docs: "cento docs", "CLI docs", "tool docs", or "registry". Use `data/cento-cli.json`, `data/tools.json`, and generated references. +- Read-only: "analysis", "summary", "suggestions", "check if", "calculate". Inspect and report without code edits unless the user asks to save an artifact. +- Implementation: "implement", "fix", "add", "wire", "e2e", "coordinate until done". Make the change and validate it. +- Plan execution: "Implement the plan" means execute the latest accepted plan, not write a second plan. +- Cloud storage: "OCI CLI", "bucket", "namespace", "Object Storage". Prefer the registered `object-storage` route. + +When intent is ambiguous, use the lowest side-effect route and ask only if the wrong route would create spend, public exposure, destructive changes, broad dispatch, or a meaningfully different artifact. + +## Common Routes + +- Local context: `cento gather-context --no-remote` +- Cross-node context: `cento gather-context` +- Platform support: `cento platforms macos` or `cento platforms linux` +- Registered command list: `cento tools` +- Built-in docs: `cento docs` +- Aliases: `cento aliases` +- Temp clipboard bridge: `cento temp run` +- Change copied Markdown: edit only `COPY_FILE` in `scripts/cento_temp.sh` +- Mac temp command: `cento cluster exec macos -- '...'` +- Linux temp command: `cento cluster exec linux -- '...'` +- VM socket to Mac/Linux: `cento bridge to-mac -- '...'` or `cento bridge to-linux -- '...'` +- One command across directories: `cento batch-exec --root DIR --pattern GLOB --command '...'` +- Search and archive a one-pager: `cento scan --query "..." --no-open` +- Repo snapshot: `cento repo-snapshot` +- Agent task: `cento agent-work create ...` + +## When To Edit Registry + +Edit `data/tools.json` only when adding or changing a durable Cento tool. Do not edit it for: + +- one-time diagnostics +- user-local commands +- temporary shell snippets +- experiments that can live under `workspace/runs` +- command bundles the user only needs to run once + +When registry edits are necessary, also validate JSON and tool visibility: + +```bash +python3 -m json.tool data/tools.json >/tmp/cento-tools-json-check.txt +cento tools | rg TOOL_ID +cento platforms macos +``` + +## Cross-Node Notes + +The expected nodes are: + +- macOS: `/Users/anovik-air/cento` +- Linux: `/home/alice/projects/cento` + +Prefer Cento wrappers over raw SSH. Use raw SSH only when wrapper discovery shows it is necessary. + +Do not assume Linux is reachable. Check `cento gather-context`, `cento cluster status`, or `cento bridge check` first. + +Mac and Linux registries can drift, but `cento temp` is intentionally not a cross-node command surface. If clipboard transport breaks, fix the local `pbcopy` shim instead of expanding `cento temp`. diff --git a/templates/agent-work-app/app.js b/templates/agent-work-app/app.js index b163371..ecfceb4 100644 --- a/templates/agent-work-app/app.js +++ b/templates/agent-work-app/app.js @@ -9,6 +9,7 @@ const savedQuerySelect = document.querySelector("#savedQuerySelect"); const queryNameInput = document.querySelector("#queryNameInput"); const newIssueButton = document.querySelector("#newIssueButton"); const headerNewIssueButton = document.querySelector("#headerNewIssueButton"); +const quickRunPipelineButton = document.querySelector("#quickRunPipelineButton"); const saveQueryButton = document.querySelector("#saveQueryButton"); const exportJsonButton = document.querySelector("#exportJsonButton"); const exportCsvButton = document.querySelector("#exportCsvButton"); @@ -32,23 +33,232 @@ const perPageButtons = document.querySelectorAll(".perPageButton"); const countLinks = document.querySelectorAll("a[data-filter]"); const mainNavLinks = document.querySelectorAll("[data-main-route]"); const primaryNavLinks = document.querySelectorAll("[data-nav-route]"); +const docsHashLinks = document.querySelectorAll(".docsSidebar nav a[href^='#'], .docsToc a[href^='#']"); const agentSummary = document.querySelector("#agentSummary"); const agentCards = document.querySelector("#agentCards"); const taskstreamNav = document.querySelector(".taskstreamNav"); +const homeView = document.querySelector("#homeView"); +const softwareDeliveryHubView = document.querySelector("#softwareDeliveryHubView"); +const devPipelineStudioView = document.querySelector("#devPipelineStudioView, .devPipelineStudioView"); +const sdHubRailLinks = document.querySelectorAll("[data-sd-hub-route]"); +const pipelineProjectSelect = document.querySelector("#pipelineProjectSelect"); +const pipelineTemplateSelect = document.querySelector("#pipelineTemplateSelect"); +const pipelineSurfaceSelect = document.querySelector("#pipelineSurfaceSelect"); +const pipelineTemplateLibrary = document.querySelector(".pipelineTemplateLibrary"); +let pipelineTemplateCards = document.querySelectorAll("[data-template-card]"); +const pipelineManifestCode = document.querySelector("#pipelineManifestCode"); +const pipelineManifestEditor = document.querySelector("#pipelineManifestEditor"); +const pipelineManifestStatus = document.querySelector("#pipelineManifestStatus"); +const pipelineFormatManifestButton = document.querySelector("#pipelineFormatManifestButton"); +const pipelineSaveManifestButton = document.querySelector("#pipelineSaveManifestButton"); +const pipelineNewTemplateButton = document.querySelector("#pipelineNewTemplateButton"); +const pipelineDuplicateButton = document.querySelector("#pipelineDuplicateButton"); +const pipelineSaveDraftButton = document.querySelector("#pipelineSaveDraftButton"); +const pipelineSaveStatus = document.querySelector("#pipelineSaveStatus"); +const pipelineProjectLabelInput = document.querySelector("#pipelineProjectLabelInput"); +const pipelineTemplateLabelInput = document.querySelector("#pipelineTemplateLabelInput"); +const pipelineTemplateDetailInput = document.querySelector("#pipelineTemplateDetailInput"); +const pipelineExecutionModelSelect = document.querySelector("#pipelineExecutionModelSelect"); +const pipelineValidationTierInput = document.querySelector("#pipelineValidationTierInput"); +const pipelineRiskSelect = document.querySelector("#pipelineRiskSelect"); +const pipelineBudgetCapInput = document.querySelector("#pipelineBudgetCapInput"); +const pipelineReadPathsInput = document.querySelector("#pipelineReadPathsInput"); +const pipelineInspectorBadge = document.querySelector("#pipelineInspectorBadge"); +const pipelineInspectorState = document.querySelector("#pipelineInspectorState"); +const pipelineInspectorNav = document.querySelector("#pipelineInspectorNav"); +const pipelineInputInspector = document.querySelector("#pipelineInputInspector"); +const pipelineInputTitleInput = document.querySelector("#pipelineInputTitleInput"); +const pipelineInputTypeSelect = document.querySelector("#pipelineInputTypeSelect"); +const pipelineInputSourceSelect = document.querySelector("#pipelineInputSourceSelect"); +const pipelineInputDetailInput = document.querySelector("#pipelineInputDetailInput"); +const pipelineInputStatusSelect = document.querySelector("#pipelineInputStatusSelect"); +const pipelineInputAutomationInput = document.querySelector("#pipelineInputAutomationInput"); +const pipelineInputRequiredCheckbox = document.querySelector("#pipelineInputRequiredCheckbox"); +const pipelineInputMutedCheckbox = document.querySelector("#pipelineInputMutedCheckbox"); +const pipelineInputFormatInput = document.querySelector("#pipelineInputFormatInput"); +const pipelineInputImageRefsInput = document.querySelector("#pipelineInputImageRefsInput"); +const pipelineInputImageNotesInput = document.querySelector("#pipelineInputImageNotesInput"); +const pipelineInputImagePreview = document.querySelector("#pipelineInputImagePreview"); +const pipelineInputQuestionsInput = document.querySelector("#pipelineInputQuestionsInput"); +const pipelineInputPathsInput = document.querySelector("#pipelineInputPathsInput"); +const pipelineInputPathPolicyInput = document.querySelector("#pipelineInputPathPolicyInput"); +const pipelineInputArtifactsInput = document.querySelector("#pipelineInputArtifactsInput"); +const pipelineInputEvidencePolicyInput = document.querySelector("#pipelineInputEvidencePolicyInput"); +const pipelineInputAnswerInput = document.querySelector("#pipelineInputAnswerInput"); +const pipelineInputAnswerValuesInput = document.querySelector("#pipelineInputAnswerValuesInput"); +const pipelineInputAnswerNotesInput = document.querySelector("#pipelineInputAnswerNotesInput"); +const pipelineInputAnswerState = document.querySelector("#pipelineInputAnswerState"); +const pipelineInputManifestPath = document.querySelector("#pipelineInputManifestPath"); +const pipelineInputSaveButton = document.querySelector("#pipelineInputSaveButton"); +const pipelineInputInspectorStatus = document.querySelector("#pipelineInputInspectorStatus"); +const pipelineIntegrationInspector = document.querySelector("#pipelineIntegrationInspector"); +const pipelineIntegrationTitleInput = document.querySelector("#pipelineIntegrationTitleInput"); +const pipelineIntegrationStatusSelect = document.querySelector("#pipelineIntegrationStatusSelect"); +const pipelineIntegrationModeSelect = document.querySelector("#pipelineIntegrationModeSelect"); +const pipelineIntegrationApplyInput = document.querySelector("#pipelineIntegrationApplyInput"); +const pipelineIntegrationConflictInput = document.querySelector("#pipelineIntegrationConflictInput"); +const pipelineIntegrationDependenciesInput = document.querySelector("#pipelineIntegrationDependenciesInput"); +const pipelineIntegrationArtifactsInput = document.querySelector("#pipelineIntegrationArtifactsInput"); +const pipelineIntegrationGatesInput = document.querySelector("#pipelineIntegrationGatesInput"); +const pipelineIntegrationRollbackInput = document.querySelector("#pipelineIntegrationRollbackInput"); +const pipelineIntegrationSaveButton = document.querySelector("#pipelineIntegrationSaveButton"); +const pipelineIntegrationInspectorStatus = document.querySelector("#pipelineIntegrationInspectorStatus"); +const pipelineIntegrationConfigPath = document.querySelector("#pipelineIntegrationConfigPath"); +const pipelineIntegrationReceiptPath = document.querySelector("#pipelineIntegrationReceiptPath"); +const pipelineValidationInspector = document.querySelector("#pipelineValidationInspector"); +const pipelineValidationTitleInput = document.querySelector("#pipelineValidationTitleInput"); +const pipelineValidationStatusSelect = document.querySelector("#pipelineValidationStatusSelect"); +const pipelineValidationTierSelect = document.querySelector("#pipelineValidationTierSelect"); +const pipelineValidationModeSelect = document.querySelector("#pipelineValidationModeSelect"); +const pipelineValidationSummaryInput = document.querySelector("#pipelineValidationSummaryInput"); +const pipelineValidationCommandsInput = document.querySelector("#pipelineValidationCommandsInput"); +const pipelineValidationEvidenceInput = document.querySelector("#pipelineValidationEvidenceInput"); +const pipelineValidationGatesInput = document.querySelector("#pipelineValidationGatesInput"); +const pipelineValidationSchemaInput = document.querySelector("#pipelineValidationSchemaInput"); +const pipelineValidationBlockingCheckbox = document.querySelector("#pipelineValidationBlockingCheckbox"); +const pipelineValidationSaveButton = document.querySelector("#pipelineValidationSaveButton"); +const pipelineValidationRunButton = document.querySelector("#pipelineValidationRunButton"); +const pipelineValidationRunStatus = document.querySelector("#pipelineValidationRunStatus"); +const pipelineValidationRunResults = document.querySelector("#pipelineValidationRunResults"); +const pipelineValidationInspectorStatus = document.querySelector("#pipelineValidationInspectorStatus"); +const pipelineValidationConfigPath = document.querySelector("#pipelineValidationConfigPath"); +const pipelineValidationReceiptPath = document.querySelector("#pipelineValidationReceiptPath"); +const pipelineValidationUseIntegrationButton = document.querySelector("#pipelineValidationUseIntegrationButton"); +const pipelineValidationIntegrationContext = document.querySelector("#pipelineValidationIntegrationContext"); +const pipelineValidationCommandRows = document.querySelector("#pipelineValidationCommandRows"); +const pipelineValidationEvidenceRows = document.querySelector("#pipelineValidationEvidenceRows"); +const pipelineValidationGateRows = document.querySelector("#pipelineValidationGateRows"); +const pipelineValidationSchemaRows = document.querySelector("#pipelineValidationSchemaRows"); +const pipelineValidationAddCommandButton = document.querySelector("#pipelineValidationAddCommandButton"); +const pipelineValidationAddEvidenceButton = document.querySelector("#pipelineValidationAddEvidenceButton"); +const pipelineValidationAddGateButton = document.querySelector("#pipelineValidationAddGateButton"); +const pipelineValidationAddSchemaButton = document.querySelector("#pipelineValidationAddSchemaButton"); +const pipelineEvidenceInspector = document.querySelector("#pipelineEvidenceInspector"); +const pipelineEvidenceTitleInput = document.querySelector("#pipelineEvidenceTitleInput"); +const pipelineEvidenceStatusSelect = document.querySelector("#pipelineEvidenceStatusSelect"); +const pipelineEvidenceKindSelect = document.querySelector("#pipelineEvidenceKindSelect"); +const pipelineEvidencePathInput = document.querySelector("#pipelineEvidencePathInput"); +const pipelineEvidenceSourcesInput = document.querySelector("#pipelineEvidenceSourcesInput"); +const pipelineEvidencePublishInput = document.querySelector("#pipelineEvidencePublishInput"); +const pipelineEvidenceRetentionInput = document.querySelector("#pipelineEvidenceRetentionInput"); +const pipelineEvidenceNotesInput = document.querySelector("#pipelineEvidenceNotesInput"); +const pipelineEvidenceSaveButton = document.querySelector("#pipelineEvidenceSaveButton"); +const pipelineEvidenceInspectorStatus = document.querySelector("#pipelineEvidenceInspectorStatus"); +const pipelineEvidenceConfigPath = document.querySelector("#pipelineEvidenceConfigPath"); +const pipelineEvidenceArtifactPath = document.querySelector("#pipelineEvidenceArtifactPath"); +const pipelineEvidenceArtifactPreview = document.querySelector("#pipelineEvidenceArtifactPreview"); +const pipelineWorkerInspectorActions = document.querySelector("#pipelineWorkerInspectorActions"); +const pipelineContractSummary = document.querySelector("#pipelineContractSummary"); +const pipelineContractPanel = document.querySelector("#pipelineContractPanel"); +const pipelineArtifactPanel = document.querySelector("#pipelineArtifactPanel"); +const pipelineLogsPanel = document.querySelector("#pipelineLogsPanel"); +const pipelineCostPanel = document.querySelector("#pipelineCostPanel"); +let currentInspectorTab = "manifest"; +let currentPipelineTab = "contracts"; +let currentPipelineExecutionStageId = "factory"; +let currentPipelineExecutionLogFilter = "all"; +let currentPipelineExecutionRunId = ""; +let pendingRunPipelinePrompt = ""; +let currentRunPipelineTemplateId = ""; +let pipelineExecutionAnimationTimers = []; +let pipelineExecutionAnimationSignature = ""; +let pipelineExecutionPollTimer = null; +let pipelineExecutionPollingActive = false; +let pipelineExecutionVisualTimer = null; +const pipelineExecutionVisualRuns = new Map(); +let pipelineExecutionEvidenceLayoutRunId = ""; +const manifestExplorerEl = document.querySelector("#manifestExplorer"); +const pipelineExecutionPage = document.querySelector("#pipeline-flow.pipelineExecutionPage"); +const pipelineExecutionStageStrip = document.querySelector("#pipelineExecutionStageStrip"); +const pipelineExecutionTimelineBody = document.querySelector("#pipelineExecutionTimelineBody"); +const pipelineExecutionTimelineWindow = document.querySelector("#pipelineExecutionTimelineWindow"); +const pipelineExecutionSelectedTitle = document.querySelector("#pipelineExecutionSelectedTitle"); +const pipelineExecutionSelectedStatus = document.querySelector("#pipelineExecutionSelectedStatus"); +const pipelineExecutionSelectedMeta = document.querySelector("#pipelineExecutionSelectedMeta"); +const pipelineExecutionStepTable = document.querySelector("#pipelineExecutionStepTable"); +const pipelineExecutionArtifactCount = document.querySelector("#pipelineExecutionArtifactCount"); +const pipelineExecutionArtifactFacts = document.querySelector("#pipelineExecutionArtifactFacts"); +const pipelineExecutionArtifactList = document.querySelector("#pipelineExecutionArtifactList"); +const pipelineExecutionValidationResults = document.querySelector("#pipelineExecutionValidationResults"); +const pipelineExecutionLogFilters = document.querySelector("#pipelineExecutionLogFilters"); +const pipelineExecutionLogSearch = document.querySelector("#pipelineExecutionLogSearch"); +const pipelineExecutionLogRows = document.querySelector("#pipelineExecutionLogRows"); +const pipelineExecutionRunButton = document.querySelector("#pipelineExecutionRunButton"); +const pipelineExecutionRunsCount = document.querySelector("#pipelineExecutionRunsCount"); +const pipelineExecutionRunsList = document.querySelector("#pipelineExecutionRunsList"); +const pipelineExecutionLiveBadge = document.querySelector("#pipelineExecutionLiveBadge"); +const pipelineExecutionNowStatus = document.querySelector("#pipelineExecutionNowStatus"); +const pipelineExecutionNowTitle = document.querySelector("#pipelineExecutionNowTitle"); +const pipelineExecutionNowMessage = document.querySelector("#pipelineExecutionNowMessage"); +const pipelineExecutionProgressSteps = document.querySelector("#pipelineExecutionProgressSteps"); +const pipelineExecutionParallelPanel = document.querySelector("#pipelineExecutionParallelPanel"); +const pipelineExecutionProofStatus = document.querySelector("#pipelineExecutionProofStatus"); +const pipelineExecutionProofFacts = document.querySelector("#pipelineExecutionProofFacts"); +const manifestSearchInput = document.querySelector("#manifestSearchInput"); +const manifestCodeEl = document.querySelector("#manifestCode"); +const manifestLineNumsEl = document.querySelector("#manifestLineNums"); +const manifestListScroll = document.querySelector("#manifestListScroll"); +const manifestReferenceCount = document.querySelector("#manifestReferenceCount"); +const manifestReferenceSummary = document.querySelector("#manifestReferenceSummary"); +const manifestReferenceTabs = document.querySelector("#manifestReferenceTabs"); +const manifestReferenceRows = document.querySelector("#manifestReferenceRows"); +const manifestReferenceMode = document.querySelector("#manifestReferenceMode"); const clusterView = document.querySelector("#clusterView"); const consultingView = document.querySelector("#consultingView"); const factoryView = document.querySelector("#factoryView"); const docsView = document.querySelector("#docsView"); const researchView = document.querySelector("#researchView"); +const codebaseIntelligenceView = document.querySelector("#codebaseIntelligenceView"); +const researchRailLinks = document.querySelectorAll("[data-research-route]"); +const ciGraphMount = document.querySelector("#ciGraphMount"); +const ciInspectorMount = document.querySelector("#ciInspectorMount"); +const ciAskMount = document.querySelector("#ciAskMount"); const factoryRunList = document.querySelector("#factoryRunList"); const factoryRunCount = document.querySelector("#factoryRunCount"); const factoryDeliveredCount = document.querySelector("#factoryDeliveredCount"); const factoryQueuedCount = document.querySelector("#factoryQueuedCount"); const factoryAiCalls = document.querySelector("#factoryAiCalls"); +const patchSwarmView = document.querySelector("#patchSwarmView"); +const patchSwarmForm = document.querySelector("#patchSwarmForm"); +const patchSwarmRepoSelect = document.querySelector("#patchSwarmRepoSelect"); +const patchSwarmTask = document.querySelector("#patchSwarmTask"); +const patchSwarmCandidateTarget = document.querySelector("#patchSwarmCandidateTarget"); +const patchSwarmMaxAgents = document.querySelector("#patchSwarmMaxAgents"); +const patchSwarmMode = document.querySelector("#patchSwarmMode"); +const patchSwarmProviders = document.querySelector("#patchSwarmProviders"); +const patchSwarmRepoState = document.querySelector("#patchSwarmRepoState"); +const patchSwarmStartStatus = document.querySelector("#patchSwarmStartStatus"); +const patchSwarmStartButton = document.querySelector("#patchSwarmStartButton"); +const patchSwarmStartHint = document.querySelector("#patchSwarmStartHint"); +const patchSwarmRefreshRepos = document.querySelector("#patchSwarmRefreshRepos"); +const patchSwarmRunList = document.querySelector("#patchSwarmRunList"); +const patchSwarmCandidateList = document.querySelector("#patchSwarmCandidateList"); +const patchSwarmDiffPreview = document.querySelector("#patchSwarmDiffPreview"); +const patchSwarmDiffTitle = document.querySelector("#patchSwarmDiffTitle"); +const patchSwarmDiffMeta = document.querySelector("#patchSwarmDiffMeta"); +const patchSwarmRunSubtitle = document.querySelector("#patchSwarmRunSubtitle"); +const patchSwarmCandidateCount = document.querySelector("#patchSwarmCandidateCount"); +const patchSwarmSelectedCount = document.querySelector("#patchSwarmSelectedCount"); +const patchSwarmValidationStatus = document.querySelector("#patchSwarmValidationStatus"); +const patchSwarmCost = document.querySelector("#patchSwarmCost"); +const patchSwarmApprovalStatus = document.querySelector("#patchSwarmApprovalStatus"); +const patchSwarmApproveButton = document.querySelector("#patchSwarmApproveButton"); +const patchSwarmApplyButton = document.querySelector("#patchSwarmApplyButton"); +const patchSwarmRejectButton = document.querySelector("#patchSwarmRejectButton"); +const patchSwarmDetailEmpty = document.querySelector("#patchSwarmDetailEmpty"); +const patchSwarmStatsPanel = document.querySelector("#patchSwarmStats"); +const patchSwarmReviewGrid = document.querySelector("#patchSwarmReviewGrid"); +const patchSwarmEvidence = document.querySelector("#patchSwarmEvidence"); const issueModal = document.querySelector("#issueModal"); const issueForm = document.querySelector("#issueForm"); +const issueModalEyebrow = document.querySelector("#issueModalEyebrow"); const issueModalTitle = document.querySelector("#issueModalTitle"); const issueSubmitButton = document.querySelector("#issueSubmitButton"); +const runPipelineTemplateField = document.querySelector("#runPipelineTemplateField"); +const runPipelineTemplateSelect = document.querySelector("#runPipelineTemplateSelect"); +const runPipelineRouteTitle = document.querySelector("#runPipelineRouteTitle"); +const runPipelineRouteDescription = document.querySelector("#runPipelineRouteDescription"); +const runPipelineInputCards = document.querySelector("#runPipelineInputCards"); const issueIdInput = document.querySelector("#issueId"); const issueSubjectInput = document.querySelector("#issueSubject"); const issueTrackerInput = document.querySelector("#issueTracker"); @@ -62,6 +272,8 @@ const issueNodeInput = document.querySelector("#issueNode"); const issueDoneRatioInput = document.querySelector("#issueDoneRatio"); const issueValidationReportInput = document.querySelector("#issueValidationReport"); const issueDescriptionInput = document.querySelector("#issueDescription"); +const issueDescriptionField = document.querySelector("#issueDescriptionField"); +const runPipelineScreenshotInput = document.querySelector("#runPipelineScreenshot"); const detailEditButton = document.querySelector("#detailEditButton"); const statusForm = document.querySelector("#statusForm"); const statusSelect = document.querySelector("#detailStatusSelect"); @@ -135,6 +347,12 @@ let detailPayload = null; let detailIssueId = null; let detailLoadingId = null; let loadedIssueMode = "create"; +let codebaseIntelligenceInitialized = false; +let codebaseIntelligencePayload = null; +let patchSwarmRepos = []; +let patchSwarmRuns = []; +let patchSwarmDetail = null; +let patchSwarmSelectedCandidateId = ""; function clampInt(value, fallback, minValue = 1) { const parsed = Number.parseInt(value, 10); @@ -149,6 +367,226 @@ function escapeHtml(value) { .replaceAll('"', """); } +const DEV_PIPELINE_ARTIFACT_ROOT = "workspace/runs/dev-pipeline-studio/docs-pages/latest/"; + +function pipelineArtifactAssetPath(value) { + let clean = String(value || "").trim(); + if (!clean) return ""; + if (/^https?:\/\//i.test(clean) || clean.startsWith("/api/artifacts?")) return clean; + clean = clean.replace(/^\/+/, ""); + if (!clean.startsWith("workspace/") && /^(execution|evidence|validation|inputs|workers|integration|integration_receipts)\//.test(clean)) { + clean = `${DEV_PIPELINE_ARTIFACT_ROOT}${clean}`; + } + return clean; +} + +function pipelineArtifactUrl(value) { + const path = pipelineArtifactAssetPath(value); + if (!path) return ""; + if (/^https?:\/\//i.test(path) || path.startsWith("/api/artifacts?")) return path; + return `/api/artifacts?path=${encodeURIComponent(path)}`; +} + +function pipelineArtifactName(value) { + const clean = String(value || "").split("?")[0].replace(/\/+$/, ""); + return clean.split("/").filter(Boolean).pop() || "image"; +} + +function pipelineArtifactBaseName(value) { + return pipelineArtifactName(value).toLowerCase(); +} + +function pipelineIsImageArtifact(value) { + return /\.(png|jpe?g|webp|gif)$/i.test(String(value || "").split("?")[0]); +} + +function pipelineExecutionArtifactKey(artifact) { + return String(artifact?.path || artifact?.name || "").trim().toLowerCase(); +} + +function pipelineExecutionArtifactFromValue(value, flow = pipelineExecutionData()) { + if (value && typeof value === "object") { + const path = String(value.path || "").trim(); + const name = String(value.name || "").trim(); + if (path || name) { + return { + name: name || pipelineArtifactName(path), + path, + size: String(value.size || ""), + exists: value.exists !== false, + }; + } + } + const clean = String(value || "").trim(); + if (!clean) return null; + const artifacts = Array.isArray(flow?.artifacts) ? flow.artifacts : []; + const cleanName = pipelineArtifactBaseName(clean); + const match = artifacts.find((artifact) => { + const artifactPath = String(artifact?.path || ""); + const artifactName = String(artifact?.name || ""); + return artifactPath === clean + || artifactName === clean + || pipelineArtifactBaseName(artifactPath) === cleanName + || pipelineArtifactBaseName(artifactName) === cleanName; + }); + if (match) return match; + if (!clean.includes("/") && !clean.startsWith("workspace/")) return null; + return { + name: pipelineArtifactName(clean), + path: clean, + size: "", + exists: true, + }; +} + +function pipelineExecutionArtifactsForRow(row = {}, flow = pipelineExecutionData()) { + const seen = new Set(); + const values = [ + ...(Array.isArray(row.artifacts) ? row.artifacts : []), + row.file, + row.receipt, + row.stdout_log, + row.stderr_log, + ]; + return values + .map((value) => pipelineExecutionArtifactFromValue(value, flow)) + .filter(Boolean) + .filter((artifact) => { + const key = pipelineExecutionArtifactKey(artifact); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function pipelineExecutionArtifactsForRows(rows = [], flow = pipelineExecutionData()) { + const seen = new Set(); + return (rows || []) + .flatMap((row) => pipelineExecutionArtifactsForRow(row, flow)) + .filter((artifact) => { + const key = pipelineExecutionArtifactKey(artifact); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function renderPipelineExecutionArtifactLinks(artifacts = [], limit = 3) { + const visible = (artifacts || []).slice(0, limit); + const extra = Math.max(0, (artifacts || []).length - visible.length); + if (!visible.length) return `-`; + return ` + ${visible.map((artifact) => { + const url = artifact.exists !== false && artifact.path ? pipelineArtifactUrl(artifact.path) : ""; + const label = artifact.name || pipelineArtifactName(artifact.path) || "artifact"; + return url + ? `${escapeHtml(label)}` + : `${escapeHtml(label)}`; + }).join("")} + ${extra ? `+${extra}` : ""} + `; +} + +function pipelineExecutionArtifactStats(flow = pipelineExecutionData()) { + const artifacts = Array.isArray(flow?.artifacts) ? flow.artifacts : []; + const ready = artifacts.filter((artifact) => artifact?.exists !== false).length; + return { + total: artifacts.length, + ready, + missing: Math.max(0, artifacts.length - ready), + }; +} + +function pipelineExecutionFactValue(flow, labels = []) { + for (const label of labels) { + const value = pipelineExecutionFact(flow, label); + if (value) return value; + } + return ""; +} + +function renderPipelineExecutionEvidenceSummary(flow = pipelineExecutionData()) { + const stats = pipelineExecutionArtifactStats(flow); + const result = flow?.validation_results || {}; + const parallel = pipelineExecutionParallelModel(flow); + const cost = pipelineExecutionFactValue(flow, ["AI cost", "Cost"]) || (flow?.total_ai_cost_usd != null ? `$${Number(flow.total_ai_cost_usd).toFixed(6)}` : "$0.000000"); + const budget = pipelineExecutionFactValue(flow, ["Budget"]) || flow?.budget || "-"; + const engine = pipelineExecutionFactValue(flow, ["Engine"]) || flow?.source || "pipeline"; + const runtime = pipelineExecutionFactValue(flow, ["Runtime"]) || flow?.run_mode || "-"; + const laneLabel = parallel.enabled + ? `${Number(parallel.task_count || parallel.tasks?.length || 0)} lanes` + : pipelineExecutionFactValue(flow, ["Frontend lane", "Schema"]) || pipelineExecutionStatusText(flow?.status); + const cards = [ + ["Run", pipelineExecutionStatusText(flow?.status), flow?.duration || "-"], + ["Evidence", `${stats.ready}/${stats.total} ready`, stats.missing ? `${stats.missing} missing` : "all linked"], + ["Work", laneLabel, runtime], + ["Gate", `${Number(result.passed || 0)}/${Number(result.total || 0)} validators`, parallel.enabled ? "serialized" : "deterministic"], + ["Cost", cost, budget], + ["Engine", engine, flow?.run_id || ""], + ]; + return cards.map(([label, value, detail]) => ` +
    +
    ${escapeHtml(label)}
    +
    ${escapeHtml(value || "-")}
    + ${escapeHtml(detail || "")} +
    + `).join(""); +} + +function pipelineExecutionArtifactClass(artifact = {}) { + if (artifact.exists === false) return "missing"; + if (pipelineIsImageArtifact(artifact.path || artifact.name)) return "image"; + return "ready"; +} + +function pipelineExecutionArtifactKind(artifact = {}) { + const name = String(artifact.name || artifact.path || "").toLowerCase(); + if (pipelineIsImageArtifact(name)) return "image"; + if (name.includes("receipt")) return "receipt"; + if (name.includes("manifest") || name.includes("workset")) return "manifest"; + if (name.includes("plan") || name.includes("request")) return "plan"; + if (name.includes("log") || name.includes("events")) return "log"; + return "artifact"; +} + +function initializePipelineExecutionEvidenceLayout(flow = pipelineExecutionData()) { + const runId = String(flow?.run_id || ""); + if (!runId || pipelineExecutionEvidenceLayoutRunId === runId) return; + pipelineExecutionEvidenceLayoutRunId = runId; + const factsDetails = pipelineExecutionArtifactFacts?.closest?.("details"); + const listDetails = pipelineExecutionArtifactList?.closest?.("details"); + const validationDetails = pipelineExecutionValidationResults?.closest?.("details"); + if (factsDetails) factsDetails.open = true; + if (listDetails) listDetails.open = false; + if (validationDetails) validationDetails.open = false; +} + +function uniquePipelineImagePaths(values) { + return Array.from(new Set((values || []).map(pipelineArtifactAssetPath).filter(pipelineIsImageArtifact))); +} + +function pipelineValueList(value) { + if (Array.isArray(value)) return value; + if (!value) return []; + return String(value).split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +} + +function renderPipelineImagePreviews(container, values) { + if (!container) return; + const images = uniquePipelineImagePaths(values); + container.classList.toggle("hidden", !images.length); + container.innerHTML = images.map((path) => { + const href = pipelineArtifactUrl(path); + const name = pipelineArtifactName(path); + return ` + + ${escapeHtml(name)} + ${escapeHtml(name)} + + `; + }).join(""); +} + function statusClass(status) { return `status-${String(status || "").toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; } @@ -429,7 +867,7 @@ function setLocationFromState() { if (perPage !== 25) params.set("per_page", String(perPage)); const suffix = params.toString() ? `?${params.toString()}` : ""; - history.replaceState(null, "", `${location.pathname.startsWith("/issues/") ? "/" : location.pathname}${suffix}`); + history.replaceState(null, "", `${location.pathname.startsWith("/issues/") ? "/issues" : location.pathname}${suffix}`); } function syncStateFromLocation() { @@ -815,8 +1253,11 @@ function issueFormPayload() { function setIssueFormMode(mode, issue = null) { loadedIssueMode = mode; - issueModalTitle.textContent = mode === "edit" ? `Edit prompt #${issue?.id || ""}`.trim() : "Create from prompt"; - if (issueSubmitButton) issueSubmitButton.textContent = mode === "edit" ? "Save issue" : "Create issue"; + const editing = mode === "edit"; + if (issueModal) issueModal.dataset.mode = editing ? "edit" : "run"; + if (issueModalEyebrow) issueModalEyebrow.textContent = editing ? "Issue editor" : "Pipeline runner"; + issueModalTitle.textContent = editing ? `Edit prompt #${issue?.id || ""}`.trim() : "Run Pipeline"; + if (issueSubmitButton) issueSubmitButton.textContent = editing ? "Save issue" : "Run pipeline"; issueIdInput.value = issue?.id ? String(issue.id) : ""; issueSubjectInput.value = issue?.subject || ""; issueTrackerInput.value = issue?.tracker || "Agent Task"; @@ -830,13 +1271,78 @@ function setIssueFormMode(mode, issue = null) { issueDoneRatioInput.value = String(issue?.done_ratio || 0); issueValidationReportInput.value = issue?.validation_report || ""; issueDescriptionInput.value = issue?.description || ""; + issueDescriptionInput.required = editing; + issueDescriptionField?.classList.toggle("hidden", !editing); + if (runPipelineScreenshotInput) runPipelineScreenshotInput.value = ""; + runPipelineTemplateField?.classList.toggle("hidden", editing); + if (runPipelineInputCards) { + runPipelineInputCards.classList.toggle("hidden", editing); + if (!editing) { + refreshRunPipelineTemplateSelect(); + renderRunPipelineInputCards(); + } + } } function openIssueModal(issue = null) { - setIssueFormMode(issue ? "edit" : "create", issue); + const editing = Boolean(issue); + setIssueFormMode(editing ? "edit" : "create", issue); issueModal.classList.remove("hidden"); issueModal.setAttribute("aria-hidden", "false"); - window.requestAnimationFrame(() => issueDescriptionInput.focus()); + window.requestAnimationFrame(() => { + const modalCard = issueModal.querySelector(".modalCard"); + if (modalCard) modalCard.scrollTop = 0; + if (editing) issueDescriptionInput.focus(); + }); +} + +async function openRunPipelineModal(prompt = "", options = {}) { + try { + const useDefaultRoute = Boolean(options.forceDefaultRoute || prompt); + if (useDefaultRoute) { + if (pipelineProjectSelect) pipelineProjectSelect.value = "hard-proreq-project"; + if (pipelineTemplateSelect) pipelineTemplateSelect.value = "hard-proreq-task"; + } + const selectedTemplate = useDefaultRoute + ? "hard-proreq-task" + : options.templateId || pipelineTemplateSelect?.value || pipelineStudioState?.selected?.template_id || "hard-proreq-task"; + currentRunPipelineTemplateId = selectedTemplate; + if (!pipelineStudioState || pipelineStudioState?.selected?.template_id !== selectedTemplate) { + if (pipelineTemplateSelect) pipelineTemplateSelect.value = selectedTemplate; + await loadPipelineStudioStateForRun(""); + } + } catch { + // The modal can still use local fallback copy; submit will surface API errors. + } + openIssueModal(); + if (prompt) issueDescriptionInput.value = prompt; + refreshRunPipelineTemplateSelect(); + renderRunPipelineInputCards(); + window.requestAnimationFrame(() => { + const modalCard = issueModal.querySelector(".modalCard"); + if (modalCard) modalCard.scrollTop = 0; + }); +} + +function capturePrefilledIssuePromptFromUrl() { + const params = new URLSearchParams(location.search); + const prompt = params.get("new_issue_prompt") || params.get("prompt") || ""; + if (!prompt) return false; + pendingRunPipelinePrompt = prompt; + params.delete("new_issue_prompt"); + params.delete("prompt"); + const nextSearch = params.toString(); + const cleanPath = location.pathname === "/issues/new" ? "/issues" : location.pathname; + history.replaceState(null, "", `${cleanPath}${nextSearch ? `?${nextSearch}` : ""}${location.hash}`); + return true; +} + +function openPrefilledIssueModalFromUrl() { + if (!pendingRunPipelinePrompt) return false; + const prompt = pendingRunPipelinePrompt; + pendingRunPipelinePrompt = ""; + void openRunPipelineModal(prompt, { forceDefaultRoute: true }); + return true; } function closeIssueModal() { @@ -851,177 +1357,4656 @@ function currentIssueIdFromDetail() { return match ? Number.parseInt(match[1], 10) : null; } -function setNavActive(route) { - const activeRoute = route || (location.pathname.startsWith("/review") ? "review" : "issues"); - const activeMain = ["cluster", "consulting", "factory", "docs", "research"].includes(activeRoute) ? activeRoute : "taskstream"; - mainNavLinks.forEach((link) => { - link.classList.toggle("active", link.dataset.mainRoute === activeMain); +let pipelineStudioProjects = { + "hard-proreq-project": { + key: "hard-proreq-project", + name: "Hard Proreq Project", + surface: "Cento pro requirements route", + surfaceValue: "hard-proreq-task", + ownedRoot: "workspace/runs/hard-proreq/outputs", + readPaths: ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "docs/**", "tests/**", "data/tools.json", ".cento/api_workers.yaml"] + }, + "parallel-pipeline-project": { + key: "parallel-pipeline-project", + name: "Parallel Pipeline Project", + surface: "Cento workset parallel execution", + surfaceValue: "parallel-pipeline", + ownedRoot: "workspace/runs/parallel-pipeline/outputs", + readPaths: ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "docs/**", "tests/**", "data/tools.json", ".cento/api_workers.yaml"] + }, + "multipipeline-proreq-project": { + key: "multipipeline-proreq-project", + name: "Multipipeline ProReq Project", + surface: "Sequential ProReq meta-pipeline", + surfaceValue: "multipipeline-proreq-chain", + ownedRoot: "workspace/runs/multipipeline-proreq/outputs", + readPaths: ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "docs/**", "tests/**", "data/tools.json", ".cento/api_workers.yaml"] + }, + "generic-easy-medium-task": { + key: "generic-easy-medium-task", + name: "Generic Easy Task", + surface: "Cento repo task", + surfaceValue: "generic-task", + ownedRoot: "workspace/runs/generic-task/outputs", + readPaths: ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "docs/**", "tests/**", "templates/pipelines/generic-task.json"] + }, + "kanji-a-day": { + key: "kanji-a-day", + name: "Kanji a Day", + surface: "Docs app page", + surfaceValue: "docs-app-page", + ownedRoot: "docs/apps/kanji-a-day/sections", + readPaths: ["docs/templates/**", "docs/apps/kanji-a-day/page.config.json"] + }, + "cento-console-docs": { + key: "cento-console-docs", + name: "Cento Console Docs", + surface: "Console documentation page", + surfaceValue: "console-doc-page", + ownedRoot: "docs/console/sections", + readPaths: ["docs/templates/**", "templates/agent-work-app/index.html"] + }, + "consulting-crm": { + key: "consulting-crm", + name: "Consulting CRM", + surface: "CRM app page", + surfaceValue: "crm-page", + ownedRoot: "templates/crm/pages", + readPaths: ["templates/crm/**", "workspace/runs/crm-app/latest.json"] + } +}; + +let pipelineStudioTemplates = { + "hard-proreq-task": { + id: "hard-proreq-task", + label: "Hard proreq task", + detail: "Manifest-backed requirement planning with optional screenshot context", + slug: "hard-proreq-task", + workerType: "hard_proreq_worker", + validationTier: "proreq-contract", + risk: "high", + tasks: "0 / 10", + budget: "$0.00", + budgetDetail: "of $20.00 budget", + selectedIndex: 0, + requiredInputs: [ + { id: "operator-thoughts", title: "Operator thoughts and full plan", detail: "Raw request, goals, constraints, assumptions, and complete plan text.", kind: "questionnaire", source: "user", status: "missing", required: true }, + { id: "generated-cento-context", title: "Generated mini Cento context", detail: "Cento-native context and repo search generated from operator input.", kind: "path", source: "auto", automation: "cento-context", status: "configured", required: true }, + { id: "ui-screenshot-request", title: "Optional muted screenshot context", detail: "Optional local screenshot path or OpenAI image edit request generated from the existing UI screenshot.", kind: "image", source: "auto", automation: "openai-image", status: "muted", required: false, muted: true, blocking: false }, + { id: "pro-backend-schema", title: "GPT Pro backend schema manifest", detail: "Strict JSON Schema for backend planning output.", kind: "details", source: "auto", automation: "schema-artifact", status: "configured", required: true }, + { id: "backend-work-handoff", title: "10-story backend handoff", detail: "Ten story manifests, parallel patch workset, manifest integration policy, validation plan, and evidence.", kind: "evidence", source: "auto", automation: "evidence-handoff", status: "configured", required: true } + ], + workers: [ + { id: "mini-cento-context", title: "Mini Cento Context", file: "mini_cento_context.json", description: "Generate Cento-native context from the operator request", stage: "repo" }, + { id: "proreq-splitter", title: "Prompt Splitter", file: "proreq_prompt_split.json", description: "Split optional screenshot context and backend story requests", stage: "blueprint", dependencies: ["mini-cento-context"] }, + { id: "backend-work-materializer", title: "Backend Work Materializer", file: "backend_work_manifest.json", description: "Create ten story manifests, parallel workset, integration, and validation manifests", stage: "blueprint", dependencies: ["proreq-splitter"] } + ], + factorySteps: [ + { id: "collect-operator-intake", title: "collect_operator_intake", file: "operator_intake.json", status: "Accepted" }, + { id: "build-cento-context", title: "build_mini_cento_context", file: "mini_cento_context.json", status: "Accepted" }, + { id: "write-ui-screenshot-request", title: "ui_screenshot_request_muted", file: "ui_screenshot_request.json", status: "Muted" }, + { id: "prepare-pro-backend-request", title: "prepare_gpt_pro_backend_request", file: "pro_backend_request.json", status: "Accepted" }, + { id: "dispatch-pro-backend-plan", title: "gpt_pro_backend_plan", file: "pro_backend_plan.json", status: "Accepted" }, + { id: "materialize-backend-work", title: "materialize_10_story_backend_work", file: "backend_work_manifest.json", status: "Accepted" } + ] + }, + "parallel-pipeline": { + id: "parallel-pipeline", + label: "Parallel workset pipeline", + detail: "Contract-first parallel workers with one serialized integration lane", + slug: "parallel-pipeline", + workerType: "parallel_workset_worker", + validationTier: "workset-contract", + risk: "high", + executionModel: "parallel", + tasks: "0 / 7", + budget: "$0.00", + budgetDetail: "of $20.00 budget", + selectedIndex: 0, + requiredInputs: [ + { id: "parallel-objective", title: "Parallel pipeline objective", detail: "Operator goal, acceptance criteria, risk limits, and completion definition.", kind: "questionnaire", source: "user", status: "missing", required: true }, + { id: "parallel-workstreams", title: "Parallel worker owned write paths", detail: "Exclusive repo-relative write paths, one independent worker task per path.", kind: "path", source: "user", status: "missing", required: true }, + { id: "parallel-read-context", title: "Generated parallel read context", detail: "Shared read context for all parallel workers.", kind: "path", source: "auto", automation: "cento-context", status: "configured", required: true }, + { id: "parallel-ui-config", title: "Parallel UI and runtime config", detail: "Max parallelism, runtime profile, budget, validation mode, and Execution Flow display policy.", kind: "details", source: "user", status: "missing", required: true }, + { id: "parallel-integrator-gate", title: "Serialized integration gate", detail: "Auto evidence proving worker patches converge through one sequential integrator.", kind: "evidence", source: "auto", automation: "sequential-integrator", status: "configured", required: true }, + { id: "parallel-validation-evidence", title: "Parallel validation and handoff evidence", detail: "Validator receipts, worker receipts, costs, logs, and residual risk notes.", kind: "evidence", source: "auto", automation: "parallel-evidence-handoff", status: "configured", required: true } + ], + workers: [ + { id: "workset-config", title: "Workset Config Contract", file: "parallel_workset_config.json", description: "Normalize objective, runtime limits, read context, and exclusive write paths", stage: "repo" }, + { id: "parallel-split", title: "Parallel Worker Split", file: "parallel_worker_split.json", description: "Split independent owned-path workstreams into runnable workset tasks", stage: "blueprint", dependencies: ["workset-config"] }, + { id: "serialized-integrator", title: "Serialized Integrator", file: "parallel_integrator.json", description: "Accept worker receipts one at a time and preserve rollback evidence", stage: "blueprint", dependencies: ["parallel-split"] } + ], + factorySteps: [ + { id: "resolve-parallel-inputs", title: "resolve_parallel_inputs", file: "execution_run.json", status: "Accepted" }, + { id: "write-parallel-workset", title: "write_parallel_workset", file: "workset.json", status: "Accepted" }, + { id: "dispatch-parallel-workers", title: "dispatch_parallel_workers", file: "workset_receipt.json", status: "Accepted" }, + { id: "integrate-sequentially", title: "integrate_sequentially", file: "integration_receipts", status: "Accepted" }, + { id: "run-parallel-validation", title: "run_parallel_validation", file: "validation_receipts", status: "Accepted" }, + { id: "collect-parallel-evidence", title: "collect_parallel_evidence", file: "parallel_evidence.json", status: "Accepted" } + ] + }, + "multipipeline-proreq-chain": { + id: "multipipeline-proreq-chain", + label: "Multipipeline ProReq chain", + detail: "Four sequential ProReq passes with guidance handoff", + slug: "multipipeline-proreq-chain", + workerType: "multipipeline_proreq_coordinator", + validationTier: "multipipeline-contract", + risk: "medium", + executionModel: "ordered", + tasks: "0 / 9", + budget: "$0.00", + budgetDetail: "request-only", + selectedIndex: 0, + requiredInputs: [ + { id: "multipipeline-objective", title: "Multipipeline objective", detail: "Operator goal, target areas, boundaries, and success evidence for four sequential ProReq passes.", kind: "questionnaire", source: "user", status: "missing", required: true }, + { id: "multipipeline-schedule-config", title: "Sequential schedule controls", detail: "Pass count, child pipeline, execution mode, UI screenshot request mode, Pro request mode, and handoff policy.", kind: "details", source: "user", status: "provided", required: true, answer: "passes: 4\nchild_pipeline: hard-proreq-task\nexecution_mode: request-artifacts\nui_screenshot: request-artifact\npro_call: request-artifact\nhandoff_policy: previous-guidance-required" }, + { id: "multipipeline-context", title: "Generated Cento route context", detail: "Shared route context for all four ProReq pass requests.", kind: "path", source: "auto", automation: "cento-context", status: "configured", required: true }, + { id: "ui-screenshot-request", title: "UI screenshot guidance request", detail: "Muted image prompt artifact for the multipipeline execution UI.", kind: "image", source: "auto", automation: "openai-image-request", status: "muted", required: false, muted: true, blocking: false }, + { id: "multipipeline-pro-request", title: "ChatGPT Pro chain request", detail: "Request artifact for manifests, integration guidance, validation guidance, and next steps.", kind: "details", source: "auto", automation: "proreq-pro-request", status: "configured", required: true }, + { id: "multipipeline-evidence", title: "Sequential chain evidence", detail: "Pass guidance, UI screenshot request, ChatGPT Pro request, roadmap, and validation evidence.", kind: "evidence", source: "auto", automation: "multipipeline-evidence-handoff", status: "configured", required: true } + ], + workers: [ + { id: "chain-intake", title: "Meta-pipeline Intake", file: "operator_intake.json", description: "Normalize objective, boundaries, and compute policy", stage: "repo" }, + { id: "chain-scheduler", title: "Sequential ProReq Scheduler", file: "multipipeline_schedule.json", description: "Schedule four ordered ProReq pass request artifacts", stage: "blueprint", dependencies: ["chain-intake"] }, + { id: "chain-handoff", title: "Guidance And Evidence Handoff", file: "multipipeline_evidence.json", description: "Collect pass guidance, UI prompt, Pro request, roadmap, and evidence", stage: "blueprint", dependencies: ["chain-scheduler"] } + ], + factorySteps: [ + { id: "collect-multipipeline-intake", title: "collect_multipipeline_intake", file: "operator_intake.json", status: "Accepted" }, + { id: "write-multipipeline-schedule", title: "write_multipipeline_schedule", file: "multipipeline_schedule.json", status: "Accepted" }, + { id: "run-proreq-pass-1", title: "proreq_pass_1_scope", file: "pass_01_proreq_request.json", status: "Accepted" }, + { id: "run-proreq-pass-2", title: "proreq_pass_2_architecture", file: "pass_02_proreq_request.json", status: "Accepted" }, + { id: "run-proreq-pass-3", title: "proreq_pass_3_integration", file: "pass_03_proreq_request.json", status: "Accepted" }, + { id: "run-proreq-pass-4", title: "proreq_pass_4_validation", file: "pass_04_proreq_request.json", status: "Accepted" }, + { id: "write-multipipeline-ui-screenshot-request", title: "write_ui_screenshot_request", file: "ui_screenshot_request.json", status: "Muted" }, + { id: "write-multipipeline-pro-request", title: "write_chatgpt_pro_request", file: "chatgpt_pro_request.json", status: "Accepted" }, + { id: "collect-multipipeline-evidence", title: "collect_multipipeline_evidence", file: "multipipeline_evidence.json", status: "Accepted" } + ] + }, + "generic-task": { + id: "generic-task", + label: "Generic easy task", + detail: "Fully configured non-UI easy programming blueprint", + slug: "generic-task", + workerType: "automation_contract_worker", + validationTier: "contract", + risk: "low", + tasks: "9 / 9", + budget: "$1.42", + budgetDetail: "of $3.00 budget", + selectedIndex: 0, + workers: [ + { id: "repo-context", title: "Repo Context Manifest", file: "repo_context.json", description: "Discover languages, tests, ownership hints, and dependency graph source", stage: "repo" }, + { id: "change-blueprint", title: "Change Plan Contract", file: "change_plan.json", description: "Define bounded change units, test units, and optional AI review gates", stage: "blueprint", dependencies: ["repo-context"] } + ], + factorySteps: [ + { id: "checkout-branch", title: "checkout_branch", file: "execution_manifest.json", status: "Accepted" }, + { id: "snapshot-repo-state", title: "snapshot_repo_state", file: "repo_snapshot.json", status: "Accepted" }, + { id: "apply-change-units", title: "apply_change_units", file: "factory_apply_receipt.json", status: "Accepted" }, + { id: "run-formatters", title: "run_formatters", file: "format_receipt.json", status: "Accepted" }, + { id: "run-focused-tests", title: "run_focused_tests", file: "focused_tests.log", status: "Accepted" }, + { id: "run-full-tests", title: "run_full_tests", file: "full_tests.log", status: "Accepted" }, + { id: "collect-diff", title: "collect_diff", file: "diff.patch", status: "Accepted" }, + { id: "collect-logs", title: "collect_logs", file: "evidence_manifest.json", status: "Accepted" } + ] + }, + "doc-page": { + id: "doc-page", + label: "Doc page creation", + detail: "Reusable web docs template", + slug: "doc-page", + workerType: "doc_page_worker", + validationTier: "smoke", + risk: "low", + tasks: "8 / 8", + budget: "$2.42", + budgetDetail: "of $5.00 budget", + selectedIndex: 0, + workers: [ + { id: "hero", title: "Hero Section Worker", file: "hero.json", description: "Generate hero section" }, + { id: "sections", title: "Body Sections Worker", file: "sections.json", description: "Generate body section structure" }, + { id: "metadata", title: "Metadata Worker", file: "metadata.json", description: "Generate metadata and navigation" }, + { id: "release", title: "Release Notes Worker", file: "release.json", description: "Generate release notes" }, + { id: "operations", title: "Operations Worker", file: "operations.json", description: "Generate operational details" }, + { id: "links", title: "Links Worker", file: "links.json", description: "Generate links and references" } + ] + }, + "dashboard-module": { + id: "dashboard-module", + label: "Dashboard module", + detail: "Operational console template", + slug: "dashboard-module", + workerType: "dashboard_module_worker", + validationTier: "screenshot", + risk: "medium", + tasks: "10 / 10", + budget: "$3.18", + budgetDetail: "of $6.50 budget", + selectedIndex: 1, + workers: [ + { id: "metrics", title: "Metric Model Worker", file: "metrics.json", description: "Define metric contracts" }, + { id: "panels", title: "Panel Layout Worker", file: "panels.json", description: "Generate dashboard panel layout" }, + { id: "actions", title: "Action Controls Worker", file: "actions.json", description: "Generate action controls" }, + { id: "adapter", title: "Data Adapter Worker", file: "adapter.json", description: "Define data adapter bindings" }, + { id: "empty-states", title: "Empty States Worker", file: "empty_states.json", description: "Generate loading and empty states" }, + { id: "screenshot", title: "Screenshot Worker", file: "screenshot.json", description: "Capture dashboard validation screenshot" } + ] + }, + "release-page": { + id: "release-page", + label: "Release evidence page", + detail: "Evidence and compliance template", + slug: "release-evidence", + workerType: "release_evidence_worker", + validationTier: "review", + risk: "medium", + tasks: "9 / 9", + budget: "$2.86", + budgetDetail: "of $5.50 budget", + selectedIndex: 2, + workers: [ + { id: "summary", title: "Change Summary Worker", file: "summary.json", description: "Generate release change summary" }, + { id: "artifacts", title: "Artifact Index Worker", file: "artifacts.json", description: "Generate artifact index" }, + { id: "approvals", title: "Approval Gate Worker", file: "approvals.json", description: "Generate approval gates" }, + { id: "cost", title: "Cost Receipt Worker", file: "cost.json", description: "Generate cost receipt" }, + { id: "risk", title: "Risk Notes Worker", file: "risk.json", description: "Generate risk notes" }, + { id: "audit", title: "Audit Trail Worker", file: "audit.json", description: "Generate audit trail" } + ] + } +}; + +let pipelineStudioControlsInitialized = false; +let pipelineStudioState = null; +let pipelineStudioOptionsReady = false; +let pipelineSelectedInputId = ""; +let pipelineSelectedIntegrationId = ""; +let pipelineSelectedValidationId = ""; +let pipelineSelectedEvidenceId = ""; +let pipelineIntegrationActiveView = "order"; + +function setPipelineField(name, value) { + document.querySelectorAll(`[data-pipeline-field="${name}"]`).forEach((element) => { + element.textContent = value; }); - primaryNavLinks.forEach((link) => { - link.classList.toggle("active", link.dataset.navRoute === activeRoute); +} + +function updateIndexedPipelineText(attribute, values) { + document.querySelectorAll(`[${attribute}]`).forEach((element) => { + const index = Number.parseInt(element.getAttribute(attribute) || "0", 10); + element.textContent = values[index] || ""; }); - if (taskstreamNav) taskstreamNav.classList.toggle("hidden", activeMain !== "taskstream"); - document.body.classList.toggle("docsMode", activeMain === "docs"); - document.body.classList.toggle("researchMode", activeMain === "research"); } -function refreshSavedQueryOptions() { - if (!savedQuerySelect) return; - const options = ['']; - for (const query of savedQueries) { - options.push(``); - } - savedQuerySelect.innerHTML = options.join(""); - if (activeQueryId) savedQuerySelect.value = activeQueryId; +function selectedPipelineStudioProject() { + return pipelineStudioProjects[pipelineProjectSelect?.value || "hard-proreq-project"] || pipelineStudioProjects["hard-proreq-project"] || pipelineStudioProjects["generic-easy-medium-task"]; } -function queryFilterPayload() { - return { - status: activeFilter, - tracker: activeTracker, - package: activePackage, - role: activeRole, - agent: activeAgent, - search: searchTerm, - updatedFrom: activeUpdatedFrom, - updatedTo: activeUpdatedTo, - evidence: activeEvidence, - risk: activeRisk, - perPage, - }; +function selectedPipelineStudioTemplate() { + return pipelineStudioTemplates[pipelineTemplateSelect?.value || "hard-proreq-task"] || pipelineStudioTemplates["hard-proreq-task"] || pipelineStudioTemplates["generic-task"]; } -async function loadSavedQueries() { - try { - const payload = await apiGetJson(`${API_BASE}/queries`); - savedQueries = Array.isArray(payload.queries) ? payload.queries : []; - } catch { - savedQueries = []; - } - refreshSavedQueryOptions(); +function optionMarkup(items, labelKey = "label") { + return items + .map((item) => ``) + .join(""); } -function queryToFilterState(rawFilters) { - let filters = {}; - if (typeof rawFilters === "string" && rawFilters.trim()) { - try { - filters = JSON.parse(rawFilters); - } catch { - filters = {}; +function renderPipelineTemplateCards(templates, selectedTemplateId) { + if (!pipelineTemplateLibrary || !templates.length) return; + pipelineTemplateLibrary.innerHTML = templates + .map((template) => { + const isActive = template.id === selectedTemplateId; + return ` + + `; + }) + .join(""); + pipelineTemplateCards = document.querySelectorAll("[data-template-card]"); +} + +function normalizePipelineState(payload) { + if (!payload || !payload.pipeline) return; + const projects = Array.isArray(payload.projects) ? payload.projects : []; + const templates = Array.isArray(payload.templates) ? payload.templates : []; + if (projects.length) { + pipelineStudioProjects = Object.fromEntries(projects.map((project) => [ + project.id, + { + key: project.id, + name: project.label || project.id, + surface: project.surface || "", + surfaceValue: project.surface_value || "", + ownedRoot: project.owned_root || "", + readPaths: Array.isArray(project.read_paths) ? project.read_paths : [] + } + ])); + if (pipelineProjectSelect) { + pipelineProjectSelect.innerHTML = optionMarkup(projects); } - } else if (rawFilters && typeof rawFilters === "object") { - filters = rawFilters; } - return { - status: String(filters.status || "open"), - tracker: String(filters.tracker || filters.trackerFilter || ""), - package: String(filters.package || ""), - role: String(filters.role || ""), - agent: String(filters.agent || ""), - search: String(filters.search || ""), - updatedFrom: String(filters.updatedFrom || filters.updated_from || ""), - updatedTo: String(filters.updatedTo || filters.updated_to || ""), - evidence: String(filters.evidence || ""), - risk: String(filters.risk || ""), - }; + if (templates.length) { + pipelineStudioTemplates = Object.fromEntries(templates.map((template) => [ + template.id, + { + id: template.id, + label: template.label || template.id, + detail: template.detail || "", + slug: template.id, + workerType: template.worker_type || "pipeline_worker", + validationTier: template.validation_tier || payload.pipeline.validation?.tier || "", + risk: template.risk || payload.pipeline.inspector?.summary?.risk_level || "", + tasks: payload.pipeline.tasks || "", + budget: payload.pipeline.budget || "", + budgetDetail: payload.pipeline.budget_detail || "", + budgetSpentUsd: Number(template.budget_spent_usd || 0), + budgetCapUsd: Number(template.budget_cap_usd || 0), + maxParallel: Number(template.max_parallel || 1), + executionModel: template.execution_model || payload.pipeline.execution_model || "", + workerStageLabel: template.worker_stage_label || payload.pipeline.worker_stage_label || "", + selectedWorker: template.selected_worker || "", + requiredInputs: Array.isArray(template.required_inputs) ? template.required_inputs : [], + factorySteps: Array.isArray(template.factory_steps) ? template.factory_steps : [], + selectedIndex: 0, + workers: Array.isArray(template.workers) ? template.workers : (payload.pipeline.workers || []) + } + ])); + } + pipelineStudioState = payload; + pipelineStudioOptionsReady = true; + if (pipelineProjectSelect) pipelineProjectSelect.value = payload.selected?.project_id || pipelineProjectSelect.value; + if (pipelineTemplateSelect) { + if (templates.length) { + pipelineTemplateSelect.innerHTML = optionMarkup(templates); + } + pipelineTemplateSelect.value = payload.selected?.template_id || pipelineTemplateSelect.value; + } + if (pipelineSurfaceSelect) { + const selectedProject = projects.find((project) => project.id === (payload.selected?.project_id || "")); + if (selectedProject?.surface_value) { + if (!Array.from(pipelineSurfaceSelect.options).some((option) => option.value === selectedProject.surface_value)) { + pipelineSurfaceSelect.add(new Option(selectedProject.surface || selectedProject.surface_value, selectedProject.surface_value)); + } + pipelineSurfaceSelect.value = selectedProject.surface_value; + } + } + renderPipelineTemplateCards(templates, payload.selected?.template_id || ""); } -function applyQueryFilters(query) { - const nextState = queryToFilterState(query?.filters || query?.query?.filters || {}); - activeQueryId = String(query?.id || query?.query?.id || ""); - applyFilterState(nextState); - if (query?.name && queryNameInput) queryNameInput.value = query.name; - page = 1; - persistFilterState(); - setLocationFromState(); - void withSpinner(loadIssues()); +function renderPipelineCards(attributeBase, items, keys) { + keys.forEach(([suffix, key]) => { + updateIndexedPipelineText(`${attributeBase}-${suffix}`, items.map((item) => item[key] || "")); + }); } -async function saveCurrentQuery() { - if (!queryNameInput || !savedQuerySelect) return; - const name = queryNameInput.value.trim() || "Custom filter"; - const payload = { - name, - filters: JSON.stringify(queryFilterPayload()), - is_default: false, - }; - const response = await fetch(`${API_BASE}/queries`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const result = await response.json(); - const query = result.query || {}; - activeQueryId = String(query.id || ""); - persistFilterState(); - await loadSavedQueries(); - savedQuerySelect.value = activeQueryId; +function pipelineExecutionStatusClass(status) { + const raw = String(status || "configured").toLowerCase().replace(/\s+/g, "-"); + if (["completed", "passed", "accepted", "applied", "healthy"].includes(raw)) return "completed"; + if (["muted", "separate-flow", "deferred"].includes(raw)) return "muted"; + if (["blocked", "rejected", "budget-blocked", "budget-exceeded", "dependency-blocked"].includes(raw)) return "blocked"; + if (["failed", "error"].includes(raw)) return "failed"; + if (["running", "active", "in-progress"].includes(raw)) return "running"; + if (["queued", "configured", "pending"].includes(raw)) return "queued"; + return raw; } -function exportIssues(format) { - const rows = filteredIssueRows(); - const issueSet = rows.map((issue) => ({ - id: issue.id, - subject: issue.subject, - tracker: issue.tracker, - status: issue.status, - priority: issue.priority, - assignee: issue.assignee, - agent: issue.agent, - role: issue.role, - package: issue.package, - node: issue.node, - updated_on: issue.updated_on, - validation_report: issue.validation_report || "", - })); - let content = ""; - let mime = "application/json"; - let filename = `agent-work-export-${Date.now()}.json`; - if (format === "csv") { - const headers = Object.keys(issueSet[0] || { - id: "", - subject: "", - tracker: "", - status: "", - priority: "", - assignee: "", - agent: "", - role: "", - package: "", - node: "", - updated_on: "", - validation_report: "", - }); - const csvEscape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; - content = [ - headers.join(","), - ...issueSet.map((row) => headers.map((header) => csvEscape(row[header])).join(",")), - ].join("\n"); - mime = "text/csv"; - filename = `agent-work-export-${Date.now()}.csv`; - } else { - content = JSON.stringify(issueSet, null, 2); - } - const blob = new Blob([content], { type: `${mime};charset=utf-8` }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); +function pipelineExecutionStatusText(status) { + return titleCasePipelineStatus(String(status || "configured").replace(/-/g, " ")); } -function currentIssuePayloadFromDetail() { - const issue = detailPayload?.issue || {}; - const customFields = detailPayload?.custom_fields || {}; - return { - id: issue.id, - subject: issue.subject, - tracker: issue.tracker, - status: issue.status, +function pipelineExecutionData() { + return pipelineStudioState?.pipeline?.execution_flow || null; +} + +function pipelineExecutionIsLive(flow = pipelineExecutionData()) { + const status = String(flow?.status || "").toLowerCase().replace(/\s+/g, "-"); + return ["running", "queued", "active", "in-progress"].includes(status); +} + +function pipelineExecutionFact(flow, label) { + const item = (flow?.facts || []).find((fact) => String(fact?.label || "").toLowerCase() === String(label || "").toLowerCase()); + return String(item?.value || ""); +} + +function pipelineExecutionPrimaryPath(flow) { + return (flow?.changed_paths || [])[0] || (flow?.target_paths || [])[0] || pipelineExecutionFact(flow, "Changed paths") || ""; +} + +function pipelineExecutionCurrentStep(flow) { + const steps = flow?.steps || []; + return steps.find((step) => pipelineExecutionStatusClass(step.status) === "running") + || steps.find((step) => pipelineExecutionStatusClass(step.status) === "queued") + || steps.find((step) => ["failed", "blocked"].includes(pipelineExecutionStatusClass(step.status))) + || [...steps].reverse().find((step) => pipelineExecutionStatusClass(step.status) === "completed") + || null; +} + +function pipelineExecutionReadinessMessage(message) { + const raw = String(message || "").trim(); + const dirty = raw.match(/^Target write path is already dirty:\s*(?:(\S{1,2})\s+)?(.+)$/i); + if (dirty) { + const status = dirty[1] || ""; + const path = (dirty[2] || dirty[1] || "").trim(); + const reason = status.includes("?") ? "untracked" : "modified"; + return `Target path is already ${reason}: ${path}. Use a fresh target path or commit/remove the existing file before rerunning.`; + } + return raw; +} + +function pipelineExecutionReadinessErrors(flow) { + return (flow?.readiness_errors || []).map(pipelineExecutionReadinessMessage).filter(Boolean); +} + +function pipelineExecutionLiveMessage(flow, step) { + const status = pipelineExecutionStatusClass(flow?.status || ""); + const stepId = String(step?.id || ""); + const changedPath = pipelineExecutionPrimaryPath(flow); + const cost = pipelineExecutionFact(flow, "AI cost") || (flow?.total_ai_cost_usd != null ? `$${Number(flow.total_ai_cost_usd).toFixed(6)}` : ""); + const readinessErrors = pipelineExecutionReadinessErrors(flow); + const parallel = pipelineExecutionParallelModel(flow); + if (readinessErrors.length) return `Blocked before dispatch: ${readinessErrors[0]}`; + if (status === "completed" && flow?.source === "cento-hard-proreq-pro") return "Hard proreq planning is complete. The schema-backed GPT pro request, backend work manifest, integration plan, validation plan, and muted frontend screenshot request are ready."; + if (status === "completed" && flow?.source === "cento-multipipeline-proreq-chain") return "Multipipeline ProReq chain is complete. Four pass requests, UI screenshot request, ChatGPT Pro request, roadmap, and evidence handoff are ready."; + if (status === "completed" && parallel.enabled) return `${parallel.task_count || parallel.tasks.length} parallel worker lanes converged through the serialized integration gate with evidence ready for handoff.`; + if (status === "completed") return `Applied ${changedPath || "the requested path"} with receipt-backed cost ${cost || "recorded"}.`; + if (status === "failed") return `Stopped at ${step?.title || "the current step"}. The receipt and logs below show the failure point.`; + if (status === "blocked") return `Blocked at ${step?.title || "readiness checks"}. No worktree change was applied.`; + if (parallel.enabled) { + const counts = pipelineExecutionParallelCounts(parallel.tasks); + if (counts.running || counts.queued) return `Fan-out is staging ${parallel.task_count || parallel.tasks.length} exclusive worker lane${(parallel.task_count || parallel.tasks.length) === 1 ? "" : "s"} before one sequential integration gate.`; + return "Parallel worker output is waiting for the serialized integration and validation gates."; + } + if (stepId === "collect-operator-intake") return "Capturing your prompt, plan, and questionnaire input into a run-scoped intake artifact."; + if (stepId === "collect-multipipeline-intake") return "Capturing the meta-pipeline objective, boundaries, and request-only compute policy."; + if (stepId === "write-multipipeline-schedule") return "Scheduling four ordered Hard ProReq pass requests with previous-guidance handoff gates."; + if (stepId?.startsWith("run-proreq-pass-")) return "Writing the next sequential ProReq request and guidance artifact from the previous pass."; + if (stepId === "write-multipipeline-ui-screenshot-request") return "Writing the muted UI screenshot guidance request for the four-pass execution view."; + if (stepId === "write-multipipeline-pro-request") return "Preparing the ChatGPT Pro request artifact for manifests, integration guidance, validation guidance, and next steps."; + if (stepId === "collect-multipipeline-evidence") return "Collecting pass guidance, UI request, Pro request, roadmap, validation status, and handoff evidence."; + if (stepId === "build-cento-context") return "Using Cento-native context and repo search to build the mini task context."; + if (stepId === "write-ui-screenshot-request") return "Writing the muted frontend screenshot request. Backend planning continues separately."; + if (stepId === "prepare-pro-backend-request") return "Preparing the GPT pro backend request with strict JSON Schema output."; + if (stepId === "dispatch-pro-backend-plan") return "Producing the backend plan artifact. Live Pro dispatch is gated by configuration."; + if (stepId === "materialize-backend-work") return "Converting the backend plan into Cento-native workstream and Codex exec commands."; + if (stepId === "api-worker") return "Calling the OpenAI patch worker. The worktree is unchanged until materialization and apply finish."; + if (stepId === "materialize-patch") return "Converting the structured API response into a local patch bundle."; + if (stepId === "integrate-sequential") return "Checking the patch in the sequential integration lane before apply."; + if (stepId === "apply-worktree") return "Applying the accepted patch to the local worktree now."; + if (stepId === "collect-receipts") return "Collecting cost, patch, validation, and handoff receipts."; + return "Run accepted. The worker dispatch waits briefly so this page can redirect before execution starts."; +} + +function pipelineExecutionDurationText(seconds, fallback = "") { + const value = Number(seconds || 0); + if (!Number.isFinite(value) || value <= 0) return fallback || "0s"; + if (value < 60) return `${Math.round(value)}s`; + const minutes = Math.floor(value / 60); + const remainder = Math.round(value % 60); + return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; +} + +function pipelineExecutionCleanTitle(stage, fallback = "") { + const title = String(stage?.short_title || stage?.title || fallback || "").trim(); + return title.replace(/^\d+\.\s*/, ""); +} + +function pipelineExecutionAggregateStatus(items) { + const statuses = (items || []).map((item) => pipelineExecutionStatusClass(item?.status)); + if (statuses.includes("failed")) return "failed"; + if (statuses.includes("blocked")) return "blocked"; + if (statuses.includes("running")) return "running"; + if (statuses.includes("queued")) return "queued"; + if (statuses.length && statuses.every((status) => ["completed", "muted"].includes(status))) return "completed"; + if (statuses.length && statuses.every((status) => status === "completed")) return "completed"; + return statuses[0] || "queued"; +} + +function pipelineExecutionStageBounds(items) { + const started = (items || []).map((item) => item?.started).find(Boolean) || ""; + const finished = [...(items || [])].reverse().map((item) => item?.finished).find(Boolean) || ""; + const durationSeconds = (items || []).reduce((sum, item) => sum + Number(item?.duration_seconds || 0), 0); + return { started, finished, durationSeconds }; +} + +function pipelineExecutionDisplayStages(rawStages = []) { + const byId = new Map((rawStages || []).map((stage) => [stage.id, stage])); + const setupStages = ["input", "repo", "blueprint"].map((id) => byId.get(id)).filter(Boolean); + const display = []; + if (setupStages.length) { + const bounds = pipelineExecutionStageBounds(setupStages); + display.push({ + id: "preflight", + index: 1, + title: "Preflight", + short_title: "Preflight", + status: pipelineExecutionAggregateStatus(setupStages), + count: "contract, repo, blueprint", + duration: pipelineExecutionDurationText(bounds.durationSeconds), + duration_seconds: bounds.durationSeconds, + started: bounds.started, + finished: bounds.finished, + steps: setupStages.map((stage) => ({ + id: stage.id, + title: pipelineExecutionCleanTitle(stage), + status: stage.status, + duration: stage.duration, + duration_seconds: stage.duration_seconds, + started: stage.started, + finished: stage.finished, + })), + }); + } + [ + ["factory", setupStages.length ? 2 : 1, "Workset Delivery"], + ["validation", setupStages.length ? 3 : 2, "Deterministic Validation"], + ["handoff", setupStages.length ? 4 : 3, "Evidence / Handoff"], + ].forEach(([id, index, fallback]) => { + const stage = byId.get(id); + if (!stage) return; + display.push({ + ...stage, + index, + title: pipelineExecutionCleanTitle(stage, fallback), + short_title: pipelineExecutionCleanTitle(stage, fallback), + }); + }); + return display.length ? display : rawStages; +} + +function pipelineExecutionNormalizeStageSelection(stageId, stages, flow) { + const clean = String(stageId || ""); + if (stages.some((stage) => stage.id === clean)) return clean; + if (["input", "repo", "blueprint"].includes(clean) && stages.some((stage) => stage.id === "preflight")) return "preflight"; + const selected = String(flow?.selected_stage_id || ""); + if (stages.some((stage) => stage.id === selected)) return selected; + if (["input", "repo", "blueprint"].includes(selected) && stages.some((stage) => stage.id === "preflight")) return "preflight"; + return stages[0]?.id || ""; +} + +function renderPipelineExecutionLivePanel(flow, stages) { + const status = pipelineExecutionStatusClass(flow?.status || ""); + const step = pipelineExecutionCurrentStep(flow); + const parallel = pipelineExecutionParallelModel(flow); + const changedPath = pipelineExecutionPrimaryPath(flow); + const worksetReceipt = flow?.workset_receipt || ""; + const cost = pipelineExecutionFact(flow, "AI cost") || (flow?.total_ai_cost_usd != null ? `$${Number(flow.total_ai_cost_usd).toFixed(6)}` : ""); + const budget = pipelineExecutionFact(flow, "Budget") || flow?.budget || ""; + const engine = pipelineExecutionFact(flow, "Engine") || flow?.source || ""; + const runtime = pipelineExecutionFact(flow, "Runtime") || ""; + const readinessErrors = pipelineExecutionReadinessErrors(flow); + + if (pipelineExecutionLiveBadge) { + pipelineExecutionLiveBadge.textContent = flow?.source === "cento-hard-proreq-pro" + ? "Hard proreq route" + : flow?.source === "cento-multipipeline-proreq-chain" + ? "Multipipeline proreq" + : (parallel.enabled ? "Parallel workset" : (flow?.source === "cento-workset-api-openai" ? "Real api-openai workset" : "Manifest run")); + pipelineExecutionLiveBadge.className = status; + } + if (pipelineExecutionNowStatus) { + pipelineExecutionNowStatus.textContent = pipelineExecutionStatusText(flow?.status || "configured"); + pipelineExecutionNowStatus.className = status; + } + if (pipelineExecutionNowTitle) { + pipelineExecutionNowTitle.textContent = parallel.enabled + ? (status === "completed" ? "Parallel run complete" : `${pipelineExecutionParallelPhase(parallel)} in progress`) + : readinessErrors.length + ? "Target path needs cleanup" + : (step?.title || (status === "completed" ? (flow?.source === "cento-hard-proreq-pro" ? "Hard proreq plan ready" : flow?.source === "cento-multipipeline-proreq-chain" ? "Multipipeline chain ready" : "Delivery completed") : (flow?.source === "cento-hard-proreq-pro" ? "Preparing hard proreq" : flow?.source === "cento-multipipeline-proreq-chain" ? "Preparing multipipeline chain" : "Preparing delivery"))); + } + if (pipelineExecutionNowMessage) { + pipelineExecutionNowMessage.textContent = pipelineExecutionLiveMessage(flow, step); + } + if (pipelineExecutionProgressSteps) { + const rows = parallel.enabled ? (stages || []).map((stage) => ({ + id: stage.id, + title: stage.short_title || stage.title, + status: stage.status, + stage_id: stage.id, + })) : (flow?.steps || []).length ? flow.steps : (stages || []).map((stage) => ({ + id: stage.id, + title: stage.short_title || stage.title, + status: stage.status, + stage_id: stage.id, + })); + pipelineExecutionProgressSteps.innerHTML = rows.map((row, index) => { + const rowStatus = pipelineExecutionStatusClass(row.status); + return ` + + `; + }).join(""); + } + if (pipelineExecutionProofStatus) { + pipelineExecutionProofStatus.textContent = worksetReceipt + ? "Receipt linked" + : (status === "blocked" ? "Blocked" : (status === "failed" ? "Failed" : (status === "completed" ? "Receipt pending" : "Waiting"))); + pipelineExecutionProofStatus.className = worksetReceipt ? "completed" : status; + } + if (pipelineExecutionProofFacts) { + const facts = readinessErrors.length ? [ + ["Blocker", readinessErrors[0]], + ["Next action", "Use a fresh target path, or commit/remove the dirty file, then run delivery again."], + ["Engine", engine], + ["Runtime", runtime], + ["Budget", budget || "-"], + ["Target path", changedPath || "-"], + ] : [ + ["Engine", engine], + ["Runtime", runtime], + ["Cost", cost || "-"], + ["Budget", budget || "-"], + ["Changed path", changedPath || "-"], + ["Workset receipt", worksetReceipt || "-"], + ]; + pipelineExecutionProofFacts.innerHTML = facts.map(([label, value]) => `
    ${escapeHtml(label)}
    ${escapeHtml(value || "-")}
    `).join(""); + } +} + +function pipelineExecutionStepRows(flow = pipelineExecutionData()) { + const steps = Array.isArray(flow?.steps) ? flow.steps : []; + if (steps.length) return steps; + return (flow?.stages || []).flatMap((stage) => Array.isArray(stage?.steps) ? stage.steps : []); +} + +function pipelineExecutionShortPath(value = "") { + const text = String(value || "").trim(); + if (!text) return ""; + const parts = text.split("/").filter(Boolean); + if (parts.length <= 3) return text; + return `${parts.slice(0, 2).join("/")}/.../${parts.slice(-1)[0]}`; +} + +function pipelineExecutionWorkerTitle(value = "", fallback = "Worker lane") { + const clean = String(value || fallback) + .replace(/^Worker:\s*/i, "") + .replace(/^Implement exclusive workstream for\s*/i, "") + .trim(); + if (/^(workspace|templates|scripts|docs|tests|data|\.cento)\//.test(clean) || /\.[a-z0-9]{1,8}$/i.test(clean)) return fallback; + return clean || fallback; +} + +function pipelineExecutionParallelCounts(tasks = []) { + return tasks.reduce((counts, task) => { + const status = pipelineExecutionStatusClass(task.status); + counts[status] = (counts[status] || 0) + 1; + return counts; + }, { completed: 0, running: 0, queued: 0, blocked: 0, failed: 0, muted: 0 }); +} + +function pipelineExecutionParallelProgress(status, index = 0) { + const clean = pipelineExecutionStatusClass(status); + if (clean === "completed") return 100; + if (clean === "running") return 58 + ((index % 3) * 9); + if (clean === "queued") return 14; + if (clean === "blocked" || clean === "failed") return 100; + if (clean === "muted") return 8; + return 22; +} + +function pipelineExecutionEase(value) { + const x = Math.max(0, Math.min(1, Number(value || 0))); + return 1 - Math.pow(1 - x, 3); +} + +function pipelineExecutionVisualState(flow) { + const runId = String(flow?.run_id || "current"); + const now = performance.now(); + let state = pipelineExecutionVisualRuns.get(runId); + if (!state) { + const live = pipelineExecutionIsLive(flow); + const status = pipelineExecutionStatusClass(flow?.status); + state = { + createdAt: now, + sawLive: live, + completedAt: status === "completed" && !live ? now - 100000 : 0, + }; + pipelineExecutionVisualRuns.set(runId, state); + if (pipelineExecutionVisualRuns.size > 8) { + const firstKey = pipelineExecutionVisualRuns.keys().next().value; + pipelineExecutionVisualRuns.delete(firstKey); + } + } + if (pipelineExecutionIsLive(flow)) state.sawLive = true; + if (pipelineExecutionStatusClass(flow?.status) === "completed" && !state.completedAt) { + state.completedAt = state.sawLive ? now : now - 100000; + } + return state; +} + +function pipelineExecutionParallelVisualModel(flow, parallel) { + const tasks = Array.isArray(parallel?.tasks) ? parallel.tasks : []; + const gateSteps = Array.isArray(parallel?.gate_steps) ? parallel.gate_steps : []; + const flowStatus = pipelineExecutionStatusClass(flow?.status); + const state = pipelineExecutionVisualState(flow); + const now = performance.now(); + if (flowStatus === "failed" || flowStatus === "blocked") { + return { + tasks: tasks.map((task, index) => ({ ...task, visual_status: pipelineExecutionStatusClass(task.status), visual_progress: pipelineExecutionParallelProgress(task.status, index) })), + gate_steps: gateSteps, + shouldTick: false, + }; + } + + if (flowStatus === "completed") { + const elapsed = Math.max(0, now - (state.completedAt || now)); + const laneDelay = Math.max(70, Math.min(130, 980 / Math.max(1, tasks.length))); + const laneFill = 620; + const visualTasks = tasks.map((task, index) => { + const actualStatus = pipelineExecutionStatusClass(task.status); + if (!state.sawLive) return { ...task, visual_status: actualStatus, visual_progress: pipelineExecutionParallelProgress(actualStatus, index) }; + const localElapsed = elapsed - (index * laneDelay); + if (localElapsed >= laneFill) return { ...task, visual_status: "completed", visual_progress: 100 }; + const progress = localElapsed <= 0 ? 86 : 86 + Math.round(14 * pipelineExecutionEase(localElapsed / laneFill)); + return { ...task, visual_status: "running", visual_progress: progress }; + }); + const gateStart = (tasks.length * laneDelay) + 420; + const gateDelay = 320; + const visualGates = gateSteps.map((step, index) => { + const localElapsed = elapsed - gateStart - (index * gateDelay); + if (!state.sawLive) return step; + if (localElapsed >= gateDelay) return { ...step, visual_status: "completed" }; + if (localElapsed >= 0) return { ...step, visual_status: "running" }; + return { ...step, visual_status: "queued" }; + }); + const doneAt = gateStart + (Math.max(1, gateSteps.length) * gateDelay) + 360; + return { + tasks: visualTasks, + gate_steps: visualGates, + shouldTick: state.sawLive && elapsed < doneAt, + }; + } + + const liveElapsed = now - state.createdAt; + return { + tasks: tasks.map((task, index) => { + const actualStatus = pipelineExecutionStatusClass(task.status); + if (["completed", "failed", "blocked"].includes(actualStatus)) { + return { ...task, visual_status: actualStatus, visual_progress: pipelineExecutionParallelProgress(actualStatus, index) }; + } + const localElapsed = liveElapsed - (index * 95); + if (localElapsed <= 0) return { ...task, visual_status: "queued", visual_progress: 12 }; + const progress = Math.min(86, 22 + Math.round(localElapsed / 42)); + return { ...task, visual_status: "running", visual_progress: progress }; + }), + gate_steps: gateSteps.map((step) => ({ ...step, visual_status: pipelineExecutionStatusClass(step.status) })), + shouldTick: pipelineExecutionIsLive(flow), + }; +} + +function pipelineExecutionParallelPhase(parallel) { + const tasks = parallel?.tasks || []; + const counts = pipelineExecutionParallelCounts(tasks); + const gateStatuses = (parallel?.gate_steps || []).map((step) => pipelineExecutionStatusClass(step.status)); + if (counts.failed || gateStatuses.includes("failed")) return "Blocked"; + if (counts.blocked || gateStatuses.includes("blocked")) return "Needs review"; + if (tasks.length && counts.completed >= tasks.length && gateStatuses.every((status) => status === "completed" || status === "muted")) return "Complete"; + if (gateStatuses.includes("running")) return "Serializing"; + if (counts.running || counts.queued) return "Fan-out"; + return "Waiting"; +} + +function pipelineExecutionParallelGateSteps(stepRows = []) { + const gateIds = new Set(["collect-worker-artifacts", "integrate-sequentially", "integrate-sequential", "run-parallel-validation", "apply-worktree", "collect-parallel-evidence", "collect-receipts"]); + return stepRows.filter((step) => gateIds.has(String(step.id || ""))).map((step) => ({ + id: String(step.id || ""), + title: String(step.title || step.id || ""), + status: pipelineExecutionStatusClass(step.status), + duration: String(step.duration || ""), + file: String(step.file || ""), + })); +} + +function pipelineExecutionParallelModel(flow = pipelineExecutionData()) { + const explicit = flow?.parallel && typeof flow.parallel === "object" ? flow.parallel : {}; + const stepRows = pipelineExecutionStepRows(flow); + const workerSteps = stepRows.filter((step) => { + const id = String(step?.id || ""); + const title = String(step?.title || ""); + return id.startsWith("parallel-worker-") || /^Worker:/i.test(title); + }); + const explicitTasks = Array.isArray(explicit.tasks) ? explicit.tasks : []; + const tasks = explicitTasks.length ? explicitTasks.map((task, index) => ({ + id: String(task.id || `parallel-worker-${index + 1}`), + title: pipelineExecutionWorkerTitle(task.title || task.id, `Worker lane ${index + 1}`), + worker_id: String(task.worker_id || task.id || `worker-${index + 1}`), + status: pipelineExecutionStatusClass(task.status), + write_paths: Array.isArray(task.write_paths) ? task.write_paths.map((path) => String(path || "")).filter(Boolean) : [], + depends_on: Array.isArray(task.depends_on) ? task.depends_on.map((item) => String(item || "")).filter(Boolean) : [], + patch_bundle: String(task.patch_bundle || ""), + integration_receipt: String(task.integration_receipt || ""), + validation_receipt: String(task.validation_receipt || ""), + })) : workerSteps.map((step, index) => ({ + id: String(step.id || `parallel-worker-${index + 1}`), + title: pipelineExecutionWorkerTitle(step.title, `Worker lane ${index + 1}`), + worker_id: String(step.worker_id || step.id || `worker-${index + 1}`), + status: pipelineExecutionStatusClass(step.status), + write_paths: pipelineTextToLines(step.file || ""), + depends_on: Array.isArray(step.dependencies) ? step.dependencies.map((item) => String(item || "")).filter(Boolean) : [], + patch_bundle: String(step.patch_bundle || ""), + integration_receipt: String(step.integration_receipt || ""), + validation_receipt: String(step.validation_receipt || ""), + duration: String(step.duration || ""), + })); + const gateSteps = pipelineExecutionParallelGateSteps(stepRows); + const enabled = explicit.enabled === true + || tasks.length > 1 + || stepRows.some((step) => String(step?.id || "").includes("parallel") || String(step?.title || "").toLowerCase().includes("parallel")); + if (!enabled) return { enabled: false, tasks: [], gate_steps: [] }; + const maxParallel = Number(explicit.max_parallel || flow?.workset_max_parallel || tasks.length || 1); + const modelPolicy = explicit.integration_model_policy || {}; + return { + ...explicit, + enabled: true, + tasks, + gate_steps: gateSteps, + max_parallel: Number.isFinite(maxParallel) && maxParallel > 0 ? maxParallel : Math.max(1, tasks.length), + task_count: Number(explicit.task_count || tasks.length || 0), + integration: explicit.integration || "sequential", + apply: explicit.apply || "sequential", + no_shared_files: explicit.no_shared_files !== false, + integration_model_policy: { + model_ceiling: modelPolicy.model_ceiling || "gpt-4.1-mini", + mode: modelPolicy.mode || "deterministic-first", + fallback: modelPolicy.fallback || "only-if-needed", + profile: modelPolicy.profile || "api-mini-integrator", + }, + summary: explicit.summary || `${tasks.length} worker lane${tasks.length === 1 ? "" : "s"}, max ${Math.max(1, Number(explicit.max_parallel || tasks.length || 1))} concurrent, one serialized integration gate`, + }; +} + +function renderPipelineExecutionParallelPanel(flow, parallel = pipelineExecutionParallelModel(flow)) { + if (!pipelineExecutionParallelPanel) return null; + if (!parallel.enabled) { + pipelineExecutionParallelPanel.classList.add("hidden"); + pipelineExecutionParallelPanel.innerHTML = ""; + return null; + } + pipelineExecutionParallelPanel.classList.remove("hidden"); + const fallbackGateSteps = [ + { id: "collect-worker-artifacts", title: "Collect worker artifacts", status: "queued", duration: "" }, + { id: "integrate-sequentially", title: "Integrate", status: "queued", duration: "" }, + { id: "run-parallel-validation", title: "Validate", status: "queued", duration: "" }, + { id: "collect-parallel-evidence", title: "Handoff", status: "queued", duration: "" }, + ]; + const baseParallel = { + ...parallel, + gate_steps: parallel.gate_steps?.length ? parallel.gate_steps : fallbackGateSteps, + }; + const visual = pipelineExecutionParallelVisualModel(flow, baseParallel); + const tasks = Array.isArray(visual.tasks) ? visual.tasks : []; + const modelPolicy = parallel.integration_model_policy || {}; + const modelCeiling = modelPolicy.model_ceiling || "gpt-4.1-mini"; + const visualTasksForCounts = tasks.map((task) => ({ ...task, status: task.visual_status || task.status })); + const visualGatesForPhase = (visual.gate_steps || []).map((step) => ({ ...step, status: step.visual_status || step.status })); + const statusCounts = pipelineExecutionParallelCounts(visualTasksForCounts); + const phase = pipelineExecutionParallelPhase({ ...parallel, tasks: visualTasksForCounts, gate_steps: visualGatesForPhase }); + const gateSteps = visual.gate_steps?.length ? visual.gate_steps : fallbackGateSteps; + const totalTasks = Number(parallel.task_count || tasks.length || 0); + pipelineExecutionParallelPanel.innerHTML = ` +
    +
    + Parallel Execution + ${escapeHtml(phase)} · ${totalTasks} lane${totalTasks === 1 ? "" : "s"} + ${Number(parallel.max_parallel || 1)} max parallel · ${escapeHtml(parallel.integration || "sequential")} integration · ${parallel.no_shared_files === false ? "write paths need review" : "exclusive write paths"} +
    +
    + ${Number(statusCounts.completed || 0)} / ${totalTasks} + workers complete + +
    +
    +
    +
    + ${tasks.map((task, index) => { + const status = pipelineExecutionStatusClass(task.visual_status || task.status); + const writePaths = Array.isArray(task.write_paths) ? task.write_paths : []; + const pathLabel = writePaths.map(pipelineExecutionShortPath).join(", ") || "write path pending"; + const progress = Number(task.visual_progress || pipelineExecutionParallelProgress(status, index)); + return ` +
    + ${index + 1} +
    + ${escapeHtml(task.title || task.id || `worker ${index + 1}`)} + ${escapeHtml(pathLabel)} +
    + ${pipelineExecutionStatusText(status)} + +
    + `; + }).join("")} +
    + +
    + `; + return visual; +} + +function selectPipelineExecutionStage(stageId) { + const flow = pipelineExecutionData(); + const stages = pipelineExecutionDisplayStages(flow?.stages || []); + currentPipelineExecutionStageId = pipelineExecutionNormalizeStageSelection(stageId, stages, flow); + renderPipelineExecutionFlow(); +} + +function setPipelineExecutionLogFilter(filter) { + currentPipelineExecutionLogFilter = filter || "all"; + renderPipelineExecutionLogs(); +} + +function clearPipelineExecutionAnimation() { + pipelineExecutionAnimationTimers.forEach((timer) => clearTimeout(timer)); + pipelineExecutionAnimationTimers = []; + pipelineExecutionAnimationSignature = ""; + pipelineExecutionPage?.classList.remove("animating", "animationComplete"); + if (pipelineExecutionPage) { + pipelineExecutionPage.dataset.animationState = "idle"; + pipelineExecutionPage.style.setProperty("--execution-animation-step-count", "0"); + } +} + +function clearPipelineExecutionVisualTimer() { + if (pipelineExecutionVisualTimer) clearTimeout(pipelineExecutionVisualTimer); + pipelineExecutionVisualTimer = null; +} + +function schedulePipelineExecutionVisualTick(visual) { + clearPipelineExecutionVisualTimer(); + if (!visual?.shouldTick || currentPipelineTab !== "execution-flow") return; + pipelineExecutionVisualTimer = setTimeout(() => { + pipelineExecutionVisualTimer = null; + renderPipelineExecutionFlow(); + }, 120); +} + +function pipelineExecutionAnimationKey(flow, stages) { + const stageKey = (stages || []).map((stage) => { + const stepKey = (stage.steps || []).map((step) => `${step.id || step.title}:${pipelineExecutionStatusClass(step.status)}:${step.duration_seconds || ""}`).join(","); + return `${stage.id}:${pipelineExecutionStatusClass(stage.status)}:${stage.duration_seconds || ""}:${stepKey}`; + }).join("|"); + const parallel = pipelineExecutionParallelModel(flow); + const parallelKey = parallel.enabled + ? (parallel.tasks || []).map((task) => `${task.id}:${pipelineExecutionStatusClass(task.status)}:${(task.write_paths || []).join(",")}`).join("|") + : ""; + return [flow?.run_id || "", pipelineExecutionStatusClass(flow?.status), flow?.event_count || "", stageKey, parallelKey].join("::"); +} + +function schedulePipelineExecutionAnimation(flow, stages, isLive) { + if (!pipelineExecutionPage) return; + const rows = Array.from(pipelineExecutionPage.querySelectorAll("[data-execution-stage-card], [data-execution-animation-row]")); + if (!rows.length) return; + const signature = pipelineExecutionAnimationKey(flow, stages); + pipelineExecutionAnimationTimers.forEach((timer) => clearTimeout(timer)); + pipelineExecutionAnimationTimers = []; + if (signature === pipelineExecutionAnimationSignature) { + pipelineExecutionPage.classList.remove("animating"); + pipelineExecutionPage.classList.add("animationComplete"); + rows.forEach((row) => row.classList.add("animationRevealed")); + return; + } + pipelineExecutionAnimationSignature = signature; + pipelineExecutionPage.classList.remove("animationComplete"); + pipelineExecutionPage.classList.add("animating"); + pipelineExecutionPage.dataset.animationState = isLive ? "live" : "replay"; + pipelineExecutionPage.style.setProperty("--execution-animation-step-count", String(rows.length)); + rows.forEach((row) => row.classList.remove("animationRevealed", "animationActive")); + const stepMs = isLive ? 105 : 48; + rows.forEach((row, index) => { + const timer = setTimeout(() => { + rows.forEach((item) => item.classList.remove("animationActive")); + row.classList.add("animationRevealed", "animationActive"); + }, index * stepMs); + pipelineExecutionAnimationTimers.push(timer); + }); + const doneTimer = setTimeout(() => { + pipelineExecutionPage.classList.remove("animating"); + pipelineExecutionPage.classList.add("animationComplete"); + pipelineExecutionPage.dataset.animationState = isLive ? "live" : "idle"; + rows.forEach((row) => row.classList.remove("animationActive")); + }, rows.length * stepMs + 240); + pipelineExecutionAnimationTimers.push(doneTimer); +} + +function stopPipelineExecutionPolling() { + if (pipelineExecutionPollTimer) clearTimeout(pipelineExecutionPollTimer); + pipelineExecutionPollTimer = null; + pipelineExecutionPollingActive = false; +} + +function ensurePipelineExecutionPolling(flow = pipelineExecutionData()) { + if (currentPipelineTab !== "execution-flow" || !pipelineExecutionIsLive(flow) || flow?.is_active_run === false || pipelineExecutionPollingActive) return; + pipelineExecutionPollingActive = true; + pipelineExecutionPollTimer = setTimeout(pollPipelineExecutionDelivery, 650); +} + +async function loadPipelineExecutionRun(runId) { + const cleanRunId = String(runId || "").trim(); + stopPipelineExecutionPolling(); + clearPipelineExecutionAnimation(); + clearPipelineExecutionVisualTimer(); + currentPipelineExecutionRunId = cleanRunId; + const payload = await loadPipelineStudioStateForRun(cleanRunId); + const flow = payload?.pipeline?.execution_flow; + currentPipelineExecutionStageId = flow?.selected_stage_id || flow?.stages?.[0]?.id || "factory"; + renderPipelineExecutionFlow(); +} + +async function pollPipelineExecutionDelivery() { + if (!pipelineExecutionPollingActive) return; + try { + await loadPipelineStudioStateForRun(""); + const flow = pipelineExecutionData(); + if (flow?.stages?.length) { + const activeStage = flow.stages.find((stage) => pipelineExecutionStatusClass(stage.status) === "running") + || flow.stages.find((stage) => pipelineExecutionStatusClass(stage.status) === "queued") + || flow.stages[flow.stages.length - 1]; + currentPipelineExecutionStageId = activeStage?.id || currentPipelineExecutionStageId; + renderPipelineExecutionFlow(); + } + if (pipelineExecutionIsLive(flow)) { + pipelineExecutionPollTimer = setTimeout(pollPipelineExecutionDelivery, 650); + return; + } + stopPipelineExecutionPolling(); + if (!pipelineExecutionParallelModel(flow).enabled) { + clearPipelineExecutionAnimation(); + } + if (pipelineExecutionRunButton) { + pipelineExecutionRunButton.disabled = false; + pipelineExecutionRunButton.textContent = "↻ Re-run"; + } + setPipelineSaveStatus(`Delivery ${pipelineExecutionStatusText(flow?.status || "completed")} at ${new Date().toLocaleTimeString()}`); + } catch (error) { + stopPipelineExecutionPolling(); + clearPipelineExecutionAnimation(); + if (pipelineExecutionRunButton) { + pipelineExecutionRunButton.disabled = false; + pipelineExecutionRunButton.textContent = "↻ Re-run"; + } + setPipelineSaveStatus(`Execution polling failed: ${error.message}`, true); + } +} + +async function runPipelineExecutionDelivery() { + if (!pipelineExecutionRunButton) return; + stopPipelineExecutionPolling(); + currentPipelineExecutionRunId = ""; + pipelineExecutionRunButton.disabled = true; + pipelineExecutionRunButton.textContent = "Starting..."; + clearPipelineExecutionAnimation(); + clearPipelineExecutionVisualTimer(); + try { + const payload = await savePipelineDraft("run_delivery", { includeManifest: false }); + if (!payload) throw new Error("Delivery did not return pipeline state"); + const flow = payload.pipeline?.execution_flow; + currentPipelineExecutionStageId = flow?.stages?.find((stage) => pipelineExecutionStatusClass(stage.status) === "running")?.id + || flow?.stages?.[0]?.id + || "input"; + renderPipelineExecutionFlow(); + if (pipelineExecutionRunButton) pipelineExecutionRunButton.textContent = pipelineExecutionIsLive(flow) ? "Running..." : "↻ Re-run"; + if (pipelineExecutionIsLive(flow) && !pipelineExecutionPollingActive) { + pipelineExecutionPollingActive = true; + pipelineExecutionPollTimer = setTimeout(pollPipelineExecutionDelivery, 250); + } else { + pipelineExecutionRunButton.disabled = false; + } + } catch (error) { + stopPipelineExecutionPolling(); + pipelineExecutionRunButton.disabled = false; + pipelineExecutionRunButton.textContent = "↻ Re-run"; + setPipelineSaveStatus(`Delivery failed: ${error.message}`, true); + } +} + +async function openDefaultPipelineRouteFromIssue(result) { + const route = result?.pipeline_route || {}; + const routeRunId = String(route.run_id || ""); + const routeUrl = String(route.url || "/dev-pipeline-studio#pipeline-flow"); + currentPipelineExecutionRunId = routeRunId; + stopPipelineExecutionPolling(); + clearPipelineExecutionAnimation(); + clearPipelineExecutionVisualTimer(); + if (pipelineProjectSelect) pipelineProjectSelect.value = route.project_id || "hard-proreq-project"; + if (pipelineTemplateSelect) pipelineTemplateSelect.value = route.template_id || "hard-proreq-task"; + history.pushState(null, "", routeUrl.includes("#") ? routeUrl : `${routeUrl}#pipeline-flow`); + showDevPipelineStudio(); + setPipelineTab("execution-flow", { updateHash: true }); + const payload = await loadPipelineStudioStateForRun(routeRunId); + const flow = payload?.pipeline?.execution_flow; + currentPipelineExecutionStageId = flow?.stages?.find((stage) => pipelineExecutionStatusClass(stage.status) === "running")?.id + || flow?.stages?.[0]?.id + || "factory"; + renderPipelineExecutionFlow(); + if (pipelineExecutionIsLive(flow) && !pipelineExecutionPollingActive) { + pipelineExecutionPollingActive = true; + pipelineExecutionPollTimer = setTimeout(pollPipelineExecutionDelivery, 250); + } + const issue = result?.issue || {}; + const prefix = issue.id ? `Prompt #${issue.id}` : "Run Pipeline"; + setPipelineSaveStatus(`${prefix} routed to pipeline run ${flow?.run_id || route.run_id || ""}`.trim()); +} + +function renderPipelineExecutionFlow() { + const flow = pipelineExecutionData(); + if (!flow) return; + const stages = pipelineExecutionDisplayStages(flow.stages || []); + const selectedRunId = currentPipelineExecutionRunId || flow.run_id || ""; + const isLive = pipelineExecutionIsLive(flow); + const parallel = pipelineExecutionParallelModel(flow); + if (pipelineExecutionPage) pipelineExecutionPage.dataset.executionSource = flow.source || "unknown"; + if (pipelineExecutionPage) pipelineExecutionPage.dataset.executionRunId = selectedRunId; + if (pipelineExecutionPage) pipelineExecutionPage.dataset.executionModel = parallel.enabled ? "parallel" : "standard"; + if (pipelineExecutionPage) { + pipelineExecutionPage.dataset.animationState = isLive ? "live" : "idle"; + } + if (isLive) { + ensurePipelineExecutionPolling(flow); + } + renderPipelineExecutionLivePanel(flow, stages); + const parallelVisual = renderPipelineExecutionParallelPanel(flow, parallel); + if (pipelineExecutionRunButton && !pipelineExecutionPollingActive) { + pipelineExecutionRunButton.disabled = isLive; + pipelineExecutionRunButton.textContent = isLive ? "Running..." : "↻ Re-run"; + } + if (pipelineExecutionRunsCount) { + const count = (flow.history || []).length; + pipelineExecutionRunsCount.textContent = `${count} run${count === 1 ? "" : "s"}`; + } + if (pipelineExecutionRunsList) { + pipelineExecutionRunsList.innerHTML = (flow.history || []).map((run) => { + const isSelected = String(run.run_id || "") === selectedRunId; + const isActive = String(run.run_id || "") === String(flow.active_run_id || ""); + const artifactCount = Number(run.artifact_count || 0); + const durationLabel = [run.duration || "", artifactCount ? `${artifactCount} artifacts` : ""].filter(Boolean).join(" · "); + return ` + + `; + }).join(""); + } + if (!currentPipelineExecutionStageId || !stages.some((stage) => stage.id === currentPipelineExecutionStageId)) { + currentPipelineExecutionStageId = pipelineExecutionNormalizeStageSelection(currentPipelineExecutionStageId, stages, flow); + } + document.querySelectorAll("[data-execution-field]").forEach((field) => { + const key = field.dataset.executionField; + const values = { + runId: flow.run_id, + status: pipelineExecutionStatusText(flow.status), + started: flow.started, + finished: flow.finished, + duration: flow.duration, + triggeredBy: flow.triggered_by, + runMode: flow.run_mode, + evidencePolicy: flow.evidence_policy, + overallStatus: pipelineExecutionStatusText(flow.status), + }; + field.textContent = values[key] || ""; + if (key === "status" || key === "overallStatus") field.className = pipelineExecutionStatusClass(flow.status); + }); + if (pipelineExecutionStageStrip) { + pipelineExecutionStageStrip.innerHTML = stages.map((stage, index) => ` + + `).join(""); + } + if (pipelineExecutionTimelineWindow) { + const first = flow.started || stages[0]?.started || ""; + const last = flow.finished || stages[stages.length - 1]?.finished || ""; + pipelineExecutionTimelineWindow.textContent = first && last ? `${first} - ${last}` : ""; + } + if (pipelineExecutionTimelineBody) { + pipelineExecutionTimelineBody.innerHTML = ` +
    StageStartProgressElapsed
    + ${stages.map((stage) => ` + + ${(stage.steps || []).map((step) => ` + + `).join("")} + `).join("")} + `; + } + if (parallel.enabled) { + pipelineExecutionPage?.classList.remove("animating", "animationComplete"); + schedulePipelineExecutionVisualTick(parallelVisual); + } else { + clearPipelineExecutionVisualTimer(); + schedulePipelineExecutionAnimation(flow, stages, isLive); + } + const selectedStage = stages.find((stage) => stage.id === currentPipelineExecutionStageId) || stages[0] || {}; + if (pipelineExecutionSelectedTitle) pipelineExecutionSelectedTitle.textContent = selectedStage.short_title || selectedStage.title || ""; + if (pipelineExecutionSelectedStatus) { + pipelineExecutionSelectedStatus.textContent = pipelineExecutionStatusText(selectedStage.status); + pipelineExecutionSelectedStatus.className = pipelineExecutionStatusClass(selectedStage.status); + } + const rows = selectedStage.steps?.length ? selectedStage.steps : stages.map((stage) => ({ + title: stage.short_title, + status: stage.status, + duration: stage.duration, + started: stage.started, + finished: stage.finished, + artifacts: stage.artifacts || [], + })); + const selectedStageArtifacts = pipelineExecutionArtifactsForRows(rows, flow); + if (pipelineExecutionSelectedMeta) { + pipelineExecutionSelectedMeta.innerHTML = [ + ["Status", pipelineExecutionStatusText(selectedStage.status)], + ["Duration", selectedStage.duration], + ["Started", selectedStage.started], + ["Finished", selectedStage.finished], + ["Items", selectedStage.count], + ["Artifacts", selectedStageArtifacts.length ? `${selectedStageArtifacts.length} linked` : "none"], + ].map(([label, value]) => `
    ${escapeHtml(label)}${escapeHtml(value || "-")}
    `).join(""); + } + if (pipelineExecutionStepTable) { + pipelineExecutionStepTable.innerHTML = ` +
    StepStatusDurationStartedFinishedArtifacts
    + ${rows.map((row) => { + const rowArtifacts = pipelineExecutionArtifactsForRow(row, flow); + return ` +
    + ${escapeHtml(row.title || row.id || "")} + ${pipelineExecutionStatusText(row.status)} + ${escapeHtml(row.duration || "")} + ${escapeHtml(row.started || "")} + ${escapeHtml(row.finished || "")} + ${renderPipelineExecutionArtifactLinks(rowArtifacts)} +
    + `; + }).join("")} + `; + } + initializePipelineExecutionEvidenceLayout(flow); + const artifactStats = pipelineExecutionArtifactStats(flow); + if (pipelineExecutionArtifactCount) pipelineExecutionArtifactCount.textContent = String(artifactStats.total); + if (pipelineExecutionArtifactFacts) { + pipelineExecutionArtifactFacts.innerHTML = renderPipelineExecutionEvidenceSummary(flow); + } + if (pipelineExecutionArtifactList) { + pipelineExecutionArtifactList.innerHTML = (flow.artifacts || []).map((artifact) => { + const url = artifact.exists && artifact.path ? pipelineArtifactUrl(artifact.path) : ""; + return ` +
    + ${escapeHtml(pipelineExecutionArtifactKind(artifact))} + ${url ? `${escapeHtml(artifact.name || "")}` : `${escapeHtml(artifact.name || "")}`} + ${escapeHtml(artifact.size || "")} + ${artifact.exists ? "Ready" : "Missing"} +
    + `; + }).join(""); + } + if (pipelineExecutionValidationResults) { + const result = flow.validation_results || {}; + const readinessErrors = pipelineExecutionReadinessErrors(flow); + pipelineExecutionValidationResults.innerHTML = ` +

    ${readinessErrors.length ? `${readinessErrors.length} readiness blocker${readinessErrors.length === 1 ? "" : "s"}` : `${Number(result.passed || 0)} / ${Number(result.total || 0)} validators passed`}

    + ${readinessErrors.map((message) => ` +
    + ! + ${escapeHtml(message)} + Blocked + readiness +
    + `).join("")} + ${(result.items || []).map((item) => ` +
    + + ${escapeHtml(item.title || "")} + ${pipelineExecutionStatusText(item.status)} + ${escapeHtml(item.duration || "")} +
    + `).join("")} + `; + } + renderPipelineExecutionLogs(); +} + +function renderPipelineExecutionLogs() { + const flow = pipelineExecutionData(); + if (!flow) return; + const logs = flow.logs || []; + const filters = ["all", "input", "repo", "blueprint", "execution", "validation", "handoff", "pipeline"]; + if (pipelineExecutionLogFilters) { + pipelineExecutionLogFilters.innerHTML = filters.map((filter) => ` + + `).join(""); + } + const search = String(pipelineExecutionLogSearch?.value || "").trim().toLowerCase(); + const visible = logs.filter((log) => { + const filterMatch = currentPipelineExecutionLogFilter === "all" || log.stage === currentPipelineExecutionLogFilter; + const text = `${log.time} ${log.stage} ${log.source} ${log.message}`.toLowerCase(); + return filterMatch && (!search || text.includes(search)); + }); + if (pipelineExecutionLogRows) { + pipelineExecutionLogRows.textContent = visible.map((log) => `${log.time} [${String(log.source || log.stage).padEnd(24).slice(0, 24)}] ${log.message}`).join("\n"); + } +} + +function selectedPipelinePayloadProject() { + const projectId = pipelineStudioState?.selected?.project_id || pipelineProjectSelect?.value || ""; + return (pipelineStudioState?.projects || []).find((project) => project.id === projectId) || null; +} + +function selectedPipelinePayloadTemplate() { + const templateId = pipelineStudioState?.selected?.template_id || pipelineTemplateSelect?.value || ""; + return (pipelineStudioState?.templates || []).find((template) => template.id === templateId) || null; +} + +function setPipelineSaveStatus(message, isError = false) { + if (pipelineSaveStatus) { + pipelineSaveStatus.textContent = message; + pipelineSaveStatus.classList.toggle("error", isError); + } +} + +function setPipelineManifestStatus(message, isError = false) { + if (pipelineManifestStatus) { + pipelineManifestStatus.textContent = message; + pipelineManifestStatus.classList.toggle("error", isError); + } +} + +const PIPELINE_INPUT_TYPES = { + text: { label: "Text", icon: "T", format: "plain text" }, + details: { label: "Details", icon: "D", format: "markdown" }, + image: { label: "Image", icon: "IMG", format: "image reference" }, + questionnaire: { label: "Questionnaire", icon: "Q", format: "structured answers" }, + path: { label: "Path target", icon: "P", format: "path list" }, + evidence: { label: "Evidence", icon: "E", format: "artifact list" } +}; + +function pipelineInputType(item) { + const raw = String(item?.kind || item?.input_type || item?.type || "text").trim().toLowerCase().replaceAll("_", "-"); + if (raw === "images" || raw === "screenshot" || raw === "mockup") return "image"; + if (raw === "question" || raw === "questions" || raw === "form") return "questionnaire"; + if (raw === "paths" || raw === "route" || raw === "routes" || raw === "command") return "path"; + if (raw === "artifact" || raw === "artifacts" || raw === "receipt") return "evidence"; + if (raw === "detail") return "details"; + return Object.prototype.hasOwnProperty.call(PIPELINE_INPUT_TYPES, raw) ? raw : "text"; +} + +function pipelineInputTypeLabel(type) { + return PIPELINE_INPUT_TYPES[type]?.label || PIPELINE_INPUT_TYPES.text.label; +} + +function pipelineInputTypeIcon(type) { + return PIPELINE_INPUT_TYPES[type]?.icon || PIPELINE_INPUT_TYPES.text.icon; +} + +function pipelineInputSource(item = {}) { + const raw = String(item.source || item.automation_source || "").trim().toLowerCase(); + if (["auto", "generated", "automation", "automated"].includes(raw)) return "auto"; + if (["user", "manual", "operator"].includes(raw)) return "user"; + return pipelineInputId(item) === "operator-thoughts" ? "user" : "auto"; +} + +function pipelineInputAutomation(item = {}) { + return String(item.automation || item.automation_source || "").trim(); +} + +function pipelineTemplateInputs(template = {}) { + if (Array.isArray(template.required_inputs)) return template.required_inputs; + if (Array.isArray(template.requiredInputs)) return template.requiredInputs; + return []; +} + +function pipelineTemplateListForRun() { + const templates = []; + const addTemplate = (template = {}) => { + const id = String(template.id || "").trim(); + if (!id) return; + const existingIndex = templates.findIndex((item) => item.id === id); + const normalized = { + ...template, + id, + label: template.label || template.name || id, + detail: template.detail || template.description || template.tagline || "", + requiredInputs: pipelineTemplateInputs(template), + }; + if (existingIndex >= 0) { + templates[existingIndex] = { + ...templates[existingIndex], + ...normalized, + requiredInputs: normalized.requiredInputs.length ? normalized.requiredInputs : templates[existingIndex].requiredInputs, + }; + return; + } + templates.push(normalized); + }; + (Array.isArray(pipelineStudioState?.templates) ? pipelineStudioState.templates : []).forEach(addTemplate); + Object.values(pipelineStudioTemplates || {}).forEach(addTemplate); + return templates; +} + +function runPipelineSelectedTemplateId() { + const selected = String(runPipelineTemplateSelect?.value || currentRunPipelineTemplateId || pipelineTemplateSelect?.value || pipelineStudioState?.selected?.template_id || "").trim(); + const templates = pipelineTemplateListForRun(); + return selected || templates[0]?.id || "hard-proreq-task"; +} + +function selectedRunPipelineTemplate(templateId = runPipelineSelectedTemplateId()) { + return pipelineTemplateListForRun().find((template) => template.id === templateId) + || pipelineStudioTemplates?.[templateId] + || selectedPipelineStudioTemplate() + || {}; +} + +function pipelineProjectListForRun() { + const projects = []; + const addProject = (project = {}) => { + const id = String(project.id || project.key || "").trim(); + if (!id || projects.some((item) => item.id === id)) return; + projects.push({ + ...project, + id, + label: project.label || project.name || id, + surface_value: project.surface_value || project.surfaceValue || project.surface || "", + }); + }; + (Array.isArray(pipelineStudioState?.projects) ? pipelineStudioState.projects : []).forEach(addProject); + Object.values(pipelineStudioProjects || {}).forEach(addProject); + return projects; +} + +function runPipelineProjectForTemplate(templateId = runPipelineSelectedTemplateId()) { + const currentProjectId = pipelineStudioState?.selected?.project_id || pipelineProjectSelect?.value || ""; + const projects = pipelineProjectListForRun(); + const surfaceMatch = projects.find((project) => String(project.surface_value || project.surfaceValue || "") === templateId); + return surfaceMatch?.id || currentProjectId || projects[0]?.id || "hard-proreq-project"; +} + +function refreshRunPipelineTemplateSelect() { + if (!runPipelineTemplateSelect) return; + const templates = pipelineTemplateListForRun(); + const requestedId = currentRunPipelineTemplateId || pipelineTemplateSelect?.value || pipelineStudioState?.selected?.template_id || templates[0]?.id || "hard-proreq-task"; + runPipelineTemplateSelect.innerHTML = templates.map((template) => { + const inputCount = pipelineTemplateInputs(template).length || template.requiredInputs?.length || 0; + const suffix = inputCount ? ` (${inputCount} inputs)` : ""; + return ``; + }).join(""); + if (templates.some((template) => template.id === requestedId)) { + runPipelineTemplateSelect.value = requestedId; + } else if (templates[0]) { + runPipelineTemplateSelect.value = templates[0].id; + } + currentRunPipelineTemplateId = runPipelineTemplateSelect.value || requestedId; +} + +function currentRunPipelineInputs() { + const templateId = runPipelineSelectedTemplateId(); + const template = selectedRunPipelineTemplate(templateId); + const stateSelectedTemplateId = pipelineStudioState?.selected?.template_id || ""; + const inputs = templateId === stateSelectedTemplateId && Array.isArray(pipelineStudioState?.pipeline?.input_cards) + ? pipelineStudioState.pipeline.input_cards + : pipelineTemplateInputs(template); + return Array.isArray(inputs) ? inputs : []; +} + +function runPipelineInputPlaceholder(kind, item = {}) { + if (kind === "path") return "templates/agent-work-app/app.js\nworkspace/runs/parallel-pipeline/execution-ui.json"; + if (kind === "image") return "workspace/runs/ui/reference.png"; + if (kind === "evidence") return "workspace/runs/pipeline/evidence.json\nworkspace/runs/pipeline/validation.log"; + if (kind === "details") return "max_parallel: 3\nruntime: api-openai\nvalidation: focused"; + if (kind === "questionnaire") return item.format || "Goal, acceptance criteria, constraints, and done definition"; + return item.format || "Manual input"; +} + +function runPipelineInputInitialValue(item = {}, index = 0, kind = pipelineInputType(item)) { + const direct = String(item.answer || item.provided_answer || item.value || item.answer_notes || "").trim(); + if (direct) return direct; + if (kind === "path") return pipelineLinesToText(item.paths || item.target_paths || item.routes); + if (kind === "image") return pipelineLinesToText(item.image_refs || item.images || item.references); + if (kind === "evidence") return pipelineLinesToText(item.artifacts || item.evidence_artifacts); + const prompt = issueDescriptionInput?.value?.trim() || ""; + const firstManualIndex = currentRunPipelineInputs().findIndex((input) => pipelineInputSource(input) === "user"); + const inputId = pipelineInputId(item, index); + if (prompt && (inputId === "operator-thoughts" || index === firstManualIndex) && ["questionnaire", "details", "text"].includes(kind)) { + return prompt; + } + return ""; +} + +const PARALLEL_CONFIG_DEFAULTS = { + max_parallel: "10", + runtime: "fixture", + integrator: "sequential", + validation: "smoke", + apply_mode: "dry-run", + budget_usd: "0.00", + max_budget_usd: "0.00", +}; + +const MULTIPIPELINE_CONFIG_DEFAULTS = { + passes: "4", + child_pipeline: "hard-proreq-task", + execution_mode: "request-artifacts", + ui_screenshot: "request-artifact", + pro_call: "request-artifact", + handoff_policy: "previous-guidance-required", +}; + +function runPipelineIsParallelTemplate(templateId = runPipelineSelectedTemplateId()) { + return templateId === "parallel-pipeline"; +} + +function runPipelineParseKeyValueConfig(text = "") { + const config = {}; + String(text || "").split(/\r?\n/).forEach((line) => { + const index = line.indexOf(":"); + if (index < 0) return; + const key = line.slice(0, index).trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, ""); + const value = line.slice(index + 1).trim(); + if (key && value) config[key] = value; + }); + return config; +} + +function runPipelineParallelConfigInitial(item = {}, index = 0) { + const config = { ...PARALLEL_CONFIG_DEFAULTS }; + const parsed = runPipelineParseKeyValueConfig(runPipelineInputInitialValue(item, index, "details")); + Object.entries(parsed).forEach(([key, value]) => { + if (key in config) config[key] = String(value); + }); + if (config.runtime !== "api-openai") { + config.budget_usd = "0.00"; + config.max_budget_usd = "0.00"; + } + return config; +} + +function runPipelineMultipipelineConfigInitial(item = {}, index = 0) { + const config = { ...MULTIPIPELINE_CONFIG_DEFAULTS }; + const parsed = runPipelineParseKeyValueConfig(runPipelineInputInitialValue(item, index, "details")); + Object.entries(parsed).forEach(([key, value]) => { + if (key in config) config[key] = String(value); + }); + config.passes = "4"; + config.child_pipeline = "hard-proreq-task"; + return config; +} + +function runPipelineSelectOptions(options, selected) { + return options.map((option) => { + const value = typeof option === "string" ? option : option.value; + const label = typeof option === "string" ? option : option.label; + return ``; + }).join(""); +} + +function renderParallelConfigControl(item = {}, index = 0) { + const config = runPipelineParallelConfigInitial(item, index); + return ` +
    + + + + + + + +
    + `; +} + +function renderMultipipelineConfigControl(item = {}, index = 0) { + const config = runPipelineMultipipelineConfigInitial(item, index); + return ` +
    + + + + + + +
    + `; +} + +function runPipelineQuestionId(question = {}, index = 0) { + const raw = String(question.id || question.key || question.name || question.prompt || question.question || "").trim(); + const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + return slug || `question-${index + 1}`; +} + +function renderRunPipelineQuestionControl(question = {}, inputIndex = 0, questionIndex = 0, required = false, optionsOverride = {}) { + const questionId = runPipelineQuestionId(question, questionIndex); + const label = question.prompt || question.question || question.label || questionId; + const answerType = String(question.answer_type || question.type || "text").toLowerCase(); + const options = Array.isArray(question.options) ? question.options.map((option) => String(option || "").trim()).filter(Boolean) : []; + const requiredAttr = required && question.required !== false ? " required" : ""; + if (options.length) { + return ` + + `; + } + const rows = optionsOverride.forceTextarea ? Number(optionsOverride.rows || 4) : (answerType === "long" || answerType === "textarea" || String(label).length > 80 ? 3 : 1); + const tag = rows > 1 ? "textarea" : "input"; + if (tag === "textarea") { + return ` + + `; + } + return ` + + `; +} + +function renderRunPipelineInputControl(item = {}, index = 0, kind = pipelineInputType(item), source = pipelineInputSource(item)) { + const inputId = pipelineInputId(item, index); + const required = item.required !== false; + const requiredAttr = required ? " required" : ""; + if (source === "auto") { + const automation = pipelineInputAutomation(item); + return ` +
    + Automated${escapeHtml(automation ? `Generated by ${automation}` : "Resolved during pipeline execution")} +

    ${escapeHtml(item.evidence_policy || item.path_policy || item.detail || "Resolved during pipeline execution.")}

    +
    + `; + } + const initialValue = runPipelineInputInitialValue(item, index, kind); + const placeholder = runPipelineInputPlaceholder(kind, item); + if (inputId === "parallel-ui-config") { + return renderParallelConfigControl(item, index); + } + if (inputId === "multipipeline-schedule-config") { + return renderMultipipelineConfigControl(item, index); + } + if (inputId === "parallel-workstreams") { + return ` +
    + Auto-generated by defaultOpen only to override worker paths or provide JSON workstreams. + +
    + `; + } + if (kind === "questionnaire" && Array.isArray(item.questions) && item.questions.length) { + const isParallelObjective = inputId === "parallel-objective"; + const isMultipipelineObjective = inputId === "multipipeline-objective"; + return ` +
    + ${item.questions.map((question, questionIndex) => renderRunPipelineQuestionControl(question, index, questionIndex, required, isParallelObjective || isMultipipelineObjective ? { forceTextarea: true, rows: questionIndex === 2 ? 3 : 4 } : {})).join("")} +
    + `; + } + if (kind === "path" || kind === "evidence" || kind === "details" || kind === "questionnaire" || kind === "text") { + const rows = kind === "path" || kind === "evidence" ? 3 : 4; + return ` + + `; + } + if (kind === "image") { + return ` + + `; + } + return ` + + `; +} + +function renderRunPipelineInputCards() { + if (!runPipelineInputCards) return; + refreshRunPipelineTemplateSelect(); + const template = selectedRunPipelineTemplate(); + const inputs = currentRunPipelineInputs(); + const manualCount = inputs.filter((item) => pipelineInputSource(item) === "user" && item.required !== false).length; + const advancedCount = inputs.filter((item) => pipelineInputSource(item) === "user" && item.required === false).length; + const autoCount = inputs.filter((item) => pipelineInputSource(item) === "auto").length; + const project = pipelineProjectListForRun().find((item) => item.id === runPipelineProjectForTemplate(template.id || runPipelineSelectedTemplateId())); + if (runPipelineRouteTitle) { + runPipelineRouteTitle.textContent = template.label || template.id || "Selected route"; + } + if (runPipelineRouteDescription) { + runPipelineRouteDescription.textContent = `${manualCount} required manual input${manualCount === 1 ? "" : "s"}, ${advancedCount} advanced override${advancedCount === 1 ? "" : "s"}, ${autoCount} automated input${autoCount === 1 ? "" : "s"} resolved by the pipeline${project?.label ? ` · ${project.label}` : ""}.`; + } + if (!inputs.length) { + runPipelineInputCards.innerHTML = ` +
    + 0 +
    +
    + No manual inputs + This pipeline can run from its configured template contract. + Automated route +
    +
    +
    ReadyNo operator form fields required
    +
    +
    +
    + `; + return; + } + runPipelineInputCards.innerHTML = inputs.map((item, index) => { + const kind = pipelineInputType(item); + const source = pipelineInputSource(item); + const automation = pipelineInputAutomation(item); + const status = String(item.status || (source === "auto" ? "configured" : "missing")).toLowerCase(); + const inputId = pipelineInputId(item, index); + const requirement = item.required === false ? "Optional" : "Required"; + const variantClass = [ + inputId === "parallel-objective" ? "objective" : "", + inputId === "parallel-workstreams" ? "advanced" : "", + source === "auto" ? "collapsedAuto" : "", + ].filter(Boolean).join(" "); + return ` +
    + ${index + 1} +
    +
    + ${escapeHtml(item.title || inputId)} + ${escapeHtml(item.detail || item.evidence_policy || item.path_policy || "")} + ${escapeHtml(pipelineInputTypeLabel(kind))} · ${escapeHtml(source === "auto" ? `Auto${automation ? `: ${automation}` : ""}` : `${requirement} user input`)} +
    +
    + ${renderRunPipelineInputControl(item, index, kind, source)} +
    +
    +
    + `; + }).join(""); +} + +function runPipelineInputValue(index) { + const structured = runPipelineInputCards?.querySelector(`.runPipelineStructuredConfig[data-run-pipeline-input-index="${index}"]`); + if (structured) { + const values = {}; + structured.querySelectorAll("[data-run-pipeline-config]").forEach((control) => { + const key = control.getAttribute("data-run-pipeline-config") || ""; + if (key) values[key] = String(control.value || "").trim(); + }); + if (values.runtime !== "api-openai") { + values.budget_usd = "0.00"; + values.max_budget_usd = "0.00"; + } + if (structured.dataset.configProfile === "multipipeline") { + return [ + `passes: ${values.passes || MULTIPIPELINE_CONFIG_DEFAULTS.passes}`, + `child_pipeline: ${values.child_pipeline || MULTIPIPELINE_CONFIG_DEFAULTS.child_pipeline}`, + `execution_mode: ${values.execution_mode || MULTIPIPELINE_CONFIG_DEFAULTS.execution_mode}`, + `ui_screenshot: ${values.ui_screenshot || MULTIPIPELINE_CONFIG_DEFAULTS.ui_screenshot}`, + `pro_call: ${values.pro_call || MULTIPIPELINE_CONFIG_DEFAULTS.pro_call}`, + `handoff_policy: ${values.handoff_policy || MULTIPIPELINE_CONFIG_DEFAULTS.handoff_policy}`, + ].join("\n"); + } + return [ + `max_parallel: ${values.max_parallel || PARALLEL_CONFIG_DEFAULTS.max_parallel}`, + `runtime: ${values.runtime || PARALLEL_CONFIG_DEFAULTS.runtime}`, + `integrator: ${values.integrator || PARALLEL_CONFIG_DEFAULTS.integrator}`, + `validation: ${values.validation || PARALLEL_CONFIG_DEFAULTS.validation}`, + `apply_mode: ${values.apply_mode || PARALLEL_CONFIG_DEFAULTS.apply_mode}`, + `budget_usd: ${values.budget_usd || PARALLEL_CONFIG_DEFAULTS.budget_usd}`, + `max_budget_usd: ${values.max_budget_usd || PARALLEL_CONFIG_DEFAULTS.max_budget_usd}`, + ].join("\n"); + } + const control = runPipelineInputCards?.querySelector(`[data-run-pipeline-input-index="${index}"][data-run-pipeline-input-id]`); + return String(control?.value || "").trim(); +} + +function runPipelineQuestionAnswers(index) { + const answers = {}; + runPipelineInputCards?.querySelectorAll(`[data-run-pipeline-input-index="${index}"][data-run-pipeline-question-id]`).forEach((control) => { + const questionId = control.getAttribute("data-run-pipeline-question-id") || ""; + const value = String(control.value || "").trim(); + if (questionId && value) answers[questionId] = value; + }); + return answers; +} + +function runPipelineInputPayload(item, index) { + const inputId = pipelineInputId(item, index); + const kind = pipelineInputType(item); + const source = pipelineInputSource(item); + const base = { id: inputId, kind, source }; + const screenshotPath = runPipelineScreenshotInput?.value?.trim() || ""; + if (source === "auto") { + if (kind === "image" && inputId === "ui-screenshot-request" && screenshotPath) { + return { ...base, image_refs: [screenshotPath], image_notes: "Operator-provided optional screenshot context." }; + } + return base; + } + const manualValue = runPipelineInputValue(index); + const prompt = issueDescriptionInput.value.trim(); + const value = manualValue || (inputId === "operator-thoughts" ? prompt : ""); + if (kind === "questionnaire") { + const answers = runPipelineQuestionAnswers(index); + const answer = value || Object.entries(answers).map(([key, answerValue]) => `${key}: ${answerValue}`).join("\n"); + return { ...base, answer, answers }; + } + if (kind === "details" || kind === "text") return { ...base, answer: value }; + if (kind === "path") return { ...base, paths: pipelineTextToLines(value) }; + if (kind === "image") { + const imageRefs = pipelineTextToLines(value || screenshotPath); + return { ...base, image_refs: imageRefs, image_notes: value }; + } + if (kind === "evidence") return { ...base, artifacts: pipelineTextToLines(value), evidence_policy: value }; + return base; +} + +function syncRunPipelineStructuredConfig(control) { + const container = control?.closest?.(".runPipelineStructuredConfig"); + if (!container) return; + const runtime = container.querySelector('[data-run-pipeline-config="runtime"]')?.value || PARALLEL_CONFIG_DEFAULTS.runtime; + container.dataset.runtime = runtime; + if (runtime !== "api-openai") { + const budget = container.querySelector('[data-run-pipeline-config="budget_usd"]'); + const cap = container.querySelector('[data-run-pipeline-config="max_budget_usd"]'); + if (budget) budget.value = "0.00"; + if (cap) cap.value = "0.00"; + } +} + +function runPipelinePayload() { + const inputs = currentRunPipelineInputs(); + const templateId = runPipelineSelectedTemplateId(); + return { + schema_version: "cento.pipeline_run_request.v1", + project_id: runPipelineProjectForTemplate(templateId), + template_id: templateId, + inputs: inputs.map(runPipelineInputPayload), + }; +} + +function pipelineElementIcon(name) { + const paths = { + pencil: '', + trash: '', + }; + return ``; +} + +function pipelineCardActionButtons(type, id, title = "") { + const safeType = escapeHtml(type); + const safeId = escapeHtml(id); + const safeTitle = escapeHtml(title || id || "element"); + return ` +
    + + +
    + `; +} + +function pipelineStageFooterButton(stage) { + return Array.from(stage?.children || []).find((child) => child.tagName === "BUTTON") || null; +} + +function pipelineInputId(item, index = 0) { + const existing = String(item?.id || "").trim(); + if (existing) return existing; + const title = String(item?.title || "").trim(); + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + return slug || `input-${index + 1}`; +} + +function pipelineInputAnswerPresent(item = {}) { + return Boolean( + item.answer_present || + String(item.answer || item.provided_answer || "").trim() || + String(item.answer_notes || item.provided_notes || "").trim() || + (Array.isArray(item.answer_values) && item.answer_values.length) || + (Array.isArray(item.provided_values) && item.provided_values.length) + ); +} + +function renderPipelineInputCards(items) { + const inputStage = document.querySelector(".stageInput"); + if (!inputStage) return; + inputStage.querySelectorAll(".pipelineCard").forEach((card) => card.remove()); + const button = pipelineStageFooterButton(inputStage); + const inputItems = items || []; + inputStage.classList.toggle("inputSequenceList", inputItems.length > 1); + inputItems.forEach((item, index) => { + const card = document.createElement("div"); + const status = String(item.status || "Missing").toLowerCase(); + const inputId = pipelineInputId(item, index); + const inputType = pipelineInputType(item); + const hasAnswer = pipelineInputAnswerPresent(item); + card.className = `pipelineCard operatorInput ${status} inputType-${inputType} ${pipelineSelectedInputId === inputId ? "selected" : ""}`; + card.dataset.inputId = inputId; + card.dataset.sequenceIndex = String(index + 1); + card.dataset.sequenceLast = String(index === inputItems.length - 1); + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-pressed", String(pipelineSelectedInputId === inputId)); + card.innerHTML = ` + ${index + 1} +
    +
    + ${escapeHtml(pipelineInputTypeIcon(inputType))} + ${escapeHtml(item.title || "")} +
    + ${escapeHtml(item.detail || item.file || item.manifest || "")} +
    + ${escapeHtml(pipelineInputTypeLabel(inputType))} + ${hasAnswer ? "Answer saved" : "Needs answer"} +
    +
    + ${pipelineCardActionButtons("input", inputId, item.title || "input")} + `; + inputStage.insertBefore(card, button || null); + }); + if (button) button.textContent = `View all (${inputItems.length})`; +} + +function pipelineValidatorId(item, index = 0) { + const existing = String(item?.id || "").trim(); + if (existing) return existing; + const title = String(item?.title || "").trim(); + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + return slug || `validator-${index + 1}`; +} + +function pipelineIntegrationId(item, index = 0) { + const existing = String(item?.id || "").trim(); + if (existing) return existing; + const title = String(item?.title || "").trim(); + const slug = title.toLowerCase().replace(/^integrate-?/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + return slug || `integration-${index + 1}`; +} + +function renderPipelineIntegrationCards(items) { + const integrationStage = document.querySelector(".stageIntegrate"); + if (!integrationStage) return; + integrationStage.querySelectorAll(".pipelineCard.receipt").forEach((card) => card.remove()); + const button = pipelineStageFooterButton(integrationStage); + (items || []).forEach((item, index) => { + const integrationId = pipelineIntegrationId(item, index); + const status = String(item.status || "Accepted").toLowerCase().replace(/\s+/g, "-"); + const card = document.createElement("div"); + card.className = `pipelineCard receipt ${status} ${pipelineSelectedIntegrationId === integrationId ? "selected" : ""}`; + card.dataset.integrationId = integrationId; + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-pressed", String(pipelineSelectedIntegrationId === integrationId)); + card.innerHTML = ` + + ${escapeHtml(item.title || "")} + ${escapeHtml(item.file || item.receipt || "integration_receipt.json")} + ${escapeHtml(item.status || "Accepted")} + ${pipelineCardActionButtons("integration", integrationId, item.title || "integration")} + `; + integrationStage.insertBefore(card, button || null); + }); + const headerCount = integrationStage.querySelector("header span"); + if (headerCount) { + const factoryLabel = document.querySelector('[data-pipeline-field="factoryStageLabel"]')?.textContent || ""; + const defaultSuffix = factoryLabel.toLowerCase().includes("factory") ? "execution steps" : "integration steps"; + headerCount.textContent = pipelineStudioState?.pipeline?.integration_count || `${(items || []).length} ${defaultSuffix}`; + } + if (button) button.textContent = `View all (${(items || []).length})`; +} + +function renderPipelineValidatorCards(items) { + const validateStage = document.querySelector(".stageValidate"); + if (!validateStage) return; + validateStage.querySelectorAll(".pipelineCard.validator").forEach((card) => card.remove()); + const button = pipelineStageFooterButton(validateStage); + (items || []).forEach((item, index) => { + const validatorId = pipelineValidatorId(item, index); + const status = String(item.status || "Configured").toLowerCase().replace(/\s+/g, "-"); + const mode = String(item.mode || "").toLowerCase(); + const card = document.createElement("div"); + card.className = `pipelineCard validator ${status} ${mode === "evidence" || validatorId === "screenshot" ? "screenshotCard" : ""} ${pipelineSelectedValidationId === validatorId ? "selected" : ""}`; + card.dataset.validatorId = validatorId; + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-pressed", String(pipelineSelectedValidationId === validatorId)); + card.innerHTML = ` + + ${escapeHtml(item.title || "")} + ${escapeHtml(item.file || item.receipt || "")} + ${mode === "evidence" || validatorId === "screenshot" ? `` : ""} + ${pipelineCardActionButtons("validation", validatorId, item.title || "validator")} + `; + validateStage.insertBefore(card, button || null); + }); + const headerCount = validateStage.querySelector("header span"); + if (headerCount) headerCount.textContent = `${(items || []).length} validators`; + if (button) button.textContent = `View all (${(items || []).length})`; +} + +function pipelineEvidenceId(item, index = 0) { + const raw = String(item?.id || item?.title || "").trim(); + const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + return slug || `evidence-${index + 1}`; +} + +function renderPipelineEvidenceCards(items) { + const evidenceStage = document.querySelector(".stageEvidence"); + if (!evidenceStage) return; + evidenceStage.querySelectorAll(".pipelineCard.evidence").forEach((card) => card.remove()); + const button = pipelineStageFooterButton(evidenceStage); + (items || []).forEach((item, index) => { + const evidenceId = pipelineEvidenceId(item, index); + const state = String(item.state || item.status || "configured").toLowerCase().replace(/\s+/g, "-"); + const card = document.createElement("div"); + card.className = `pipelineCard evidence ${state} ${pipelineSelectedEvidenceId === evidenceId ? "selected" : ""}`; + card.dataset.evidenceId = evidenceId; + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-pressed", String(pipelineSelectedEvidenceId === evidenceId)); + const statusText = escapeHtml(item.status || titleCasePipelineStatus(item.state || "Configured")); + const statusNode = /events|\$/.test(String(item.status || "")) ? `${statusText}` : `${statusText}`; + card.innerHTML = ` + + ${escapeHtml(item.title || "")} + ${escapeHtml(item.file || item.path || "evidence.json")} + ${statusNode} + ${pipelineCardActionButtons("evidence", evidenceId, item.title || "evidence")} + `; + evidenceStage.insertBefore(card, button || null); + }); + const headerCount = evidenceStage.querySelector("header span"); + if (headerCount) headerCount.textContent = `${(items || []).length} artifacts`; + if (button) button.textContent = `View all (${(items || []).length})`; +} + +function workerStageKey(worker, index = 0) { + const raw = String(worker?.stage || worker?.stage_kind || worker?.lane || "").trim().toLowerCase().replaceAll("_", "-"); + if (raw === "blueprint" || raw === "change-blueprint" || raw === "plan") return "blueprint"; + if (raw === "repo" || raw === "repo-discovery" || raw === "context") return "repo"; + if (String(worker?.id || "").includes("blueprint") || String(worker?.id || "") === "plan") return "blueprint"; + return index === 1 ? "blueprint" : "repo"; +} + +function renderPipelineWorkerCards(items) { + const stages = { + repo: document.querySelector(".stageWorkers"), + blueprint: document.querySelector(".stageBlueprint") + }; + Object.values(stages).forEach((stage) => { + stage?.querySelectorAll(".pipelineCard.worker").forEach((card) => card.remove()); + }); + const counts = { repo: 0, blueprint: 0 }; + (items || []).forEach((worker, index) => { + const stageKey = workerStageKey(worker, index); + const stage = stages[stageKey] || stages.repo; + const button = pipelineStageFooterButton(stage); + if (!stage) return; + counts[stageKey] = (counts[stageKey] || 0) + 1; + const card = document.createElement("div"); + card.className = `pipelineCard worker ${worker.selected ? "selected" : ""}`; + card.dataset.workerId = worker.id || ""; + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-pressed", String(Boolean(worker.selected))); + card.innerHTML = ` + + ${escapeHtml(worker.title || worker.id || "Contract")} + ${escapeHtml(worker.file || worker.detail || "")} + ${escapeHtml(worker.status || "Ready")} + ${pipelineCardActionButtons("worker", worker.id || "", worker.title || worker.id || "worker")} + `; + stage.insertBefore(card, button || null); + }); + Object.entries(stages).forEach(([stageKey, stage]) => { + const count = counts[stageKey] || 0; + const label = `${count} ${count === 1 ? "contract" : "contracts"}`; + const headerCount = stage?.querySelector(`[data-worker-stage-count="${stageKey}"]`); + const button = pipelineStageFooterButton(stage); + if (headerCount) headerCount.textContent = label; + if (button) button.textContent = `View all (${count})`; + }); +} + +function selectedPipelineInput(inputId = pipelineSelectedInputId) { + const inputs = pipelineStudioState?.pipeline?.input_cards || selectedPipelinePayloadTemplate()?.required_inputs || selectedPipelineStudioTemplate()?.requiredInputs || []; + return (inputs || []).find((item, index) => pipelineInputId(item, index) === inputId) || null; +} + +function selectedPipelineValidator(validatorId = pipelineSelectedValidationId) { + const validators = pipelineStudioState?.pipeline?.validators || selectedPipelinePayloadTemplate()?.validators || []; + return (validators || []).find((item, index) => pipelineValidatorId(item, index) === validatorId) || null; +} + +function selectedPipelineIntegration(integrationId = pipelineSelectedIntegrationId) { + const integrations = pipelineStudioState?.pipeline?.integration || []; + return (integrations || []).find((item, index) => pipelineIntegrationId(item, index) === integrationId) || null; +} + +function selectedPipelineEvidence(evidenceId = pipelineSelectedEvidenceId) { + const evidence = pipelineStudioState?.pipeline?.evidence || []; + return (evidence || []).find((item, index) => pipelineEvidenceId(item, index) === evidenceId) || null; +} + +function pipelineLinesToText(value) { + if (Array.isArray(value)) return value.map((item) => String(item || "").trim()).filter(Boolean).join("\n"); + return String(value || "").trim(); +} + +function pipelineTextToLines(value) { + return String(value || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +} + +function uniquePipelineLines(values) { + return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))); +} + +function setPipelineTextareaLines(textarea, values) { + if (!textarea) return; + textarea.value = uniquePipelineLines(values).join("\n"); +} + +function appendPipelineTextareaLines(textarea, values) { + if (!textarea) return; + setPipelineTextareaLines(textarea, [...pipelineTextToLines(textarea.value), ...values]); +} + +function pipelineQuestionItemsToText(value) { + if (!Array.isArray(value)) return String(value || "").trim(); + return value + .map((item) => { + if (typeof item === "string") return item.trim(); + if (!item || typeof item !== "object") return ""; + const prompt = String(item.prompt || item.question || "").trim(); + const required = item.required === false ? "optional" : "required"; + const answerType = String(item.answer_type || item.type || "text").trim(); + const options = Array.isArray(item.options) ? item.options.join(", ") : String(item.options || "").trim(); + return [prompt, required, answerType, options].filter(Boolean).join(" | "); + }) + .filter(Boolean) + .join("\n"); +} + +function pipelineTextToQuestionItems(value) { + return String(value || "") + .split(/\r?\n/) + .map((line, index) => { + const parts = line.split("|").map((part) => part.trim()); + const prompt = parts[0] || ""; + if (!prompt) return null; + const requiredText = (parts[1] || "required").toLowerCase(); + const answerType = parts[2] || "text"; + const options = (parts[3] || "").split(",").map((option) => option.trim()).filter(Boolean); + return { + id: `q-${index + 1}`, + prompt, + required: requiredText !== "optional" && requiredText !== "false", + answer_type: answerType, + options + }; + }) + .filter(Boolean); +} + +function renderValidationCommandRows(commands) { + if (!pipelineValidationCommandRows) return; + const rows = uniquePipelineLines(commands); + pipelineValidationCommandRows.innerHTML = rows.map((command, index) => ` +
    + ${index + 1} + + +
    + `).join(""); +} + +function renderValidationEvidenceRows(evidence) { + if (!pipelineValidationEvidenceRows) return; + const rows = uniquePipelineLines(evidence); + pipelineValidationEvidenceRows.innerHTML = rows.map((item, index) => { + const type = item.match(/\.(png|jpg|jpeg|webp)$/i) ? "screenshot" : item.match(/\.(ndjson|log|txt)$/i) ? "log" : item.includes("receipt") ? "receipt" : "artifact"; + return ` +
    + + + +
    + `; + }).join(""); +} + +function renderValidationGateRows(gates) { + if (!pipelineValidationGateRows) return; + const rows = uniquePipelineLines(gates); + pipelineValidationGateRows.innerHTML = rows.map((gate, index) => ` +
    + + + +
    + `).join(""); +} + +function renderValidationSchemaRows(paths) { + if (!pipelineValidationSchemaRows) return; + const rows = uniquePipelineLines(paths); + pipelineValidationSchemaRows.innerHTML = rows.map((path, index) => { + const scope = path.includes("integration") ? "integration" : path.includes("validation") ? "validation" : "pipeline"; + return ` +
    + + + +
    + `; + }).join(""); +} + +function renderPipelineValidationTypedEditors(validator = {}) { + renderValidationCommandRows(validator.commands || pipelineTextToLines(pipelineValidationCommandsInput?.value || "")); + renderValidationEvidenceRows(validator.evidence || pipelineTextToLines(pipelineValidationEvidenceInput?.value || "")); + renderValidationGateRows(validator.gates || pipelineTextToLines(pipelineValidationGatesInput?.value || "")); + renderValidationSchemaRows(validator.schema_paths || pipelineTextToLines(pipelineValidationSchemaInput?.value || "")); +} + +function collectValidationRowValues(selector, valueSelector) { + return Array.from(document.querySelectorAll(selector)) + .map((row) => row.querySelector(valueSelector)?.value?.trim() || "") + .filter(Boolean); +} + +function syncValidationRowsToTextareas() { + setPipelineTextareaLines(pipelineValidationCommandsInput, collectValidationRowValues("[data-validation-command-row]", "[data-validation-command-value]")); + setPipelineTextareaLines(pipelineValidationEvidenceInput, collectValidationRowValues("[data-validation-evidence-row]", "[data-validation-evidence-value]")); + setPipelineTextareaLines(pipelineValidationGatesInput, collectValidationRowValues("[data-validation-gate-row]", "[data-validation-gate-value]")); + setPipelineTextareaLines(pipelineValidationSchemaInput, collectValidationRowValues("[data-validation-schema-row]", "[data-validation-schema-value]")); +} + +function ensureValidationIntegrationGate() { + const referencesIntegrationLane = [ + pipelineValidationEvidenceInput?.value || "", + pipelineValidationSchemaInput?.value || "", + pipelineValidationCommandsInput?.value || "" + ].some((value) => value.includes("integration/integration_lane.json")); + if (!referencesIntegrationLane) return; + appendPipelineTextareaLines(pipelineValidationGatesInput, ["Integration lane has no blocked or rejected receipts"]); +} + +function syncValidationTextareaToRows(kind) { + if (kind === "commands") renderValidationCommandRows(pipelineTextToLines(pipelineValidationCommandsInput?.value || "")); + if (kind === "evidence") renderValidationEvidenceRows(pipelineTextToLines(pipelineValidationEvidenceInput?.value || "")); + if (kind === "gates") renderValidationGateRows(pipelineTextToLines(pipelineValidationGatesInput?.value || "")); + if (kind === "schema") renderValidationSchemaRows(pipelineTextToLines(pipelineValidationSchemaInput?.value || "")); +} + +function addPipelineValidationRow(kind, value = "") { + if (kind === "commands") { + renderValidationCommandRows([...collectValidationRowValues("[data-validation-command-row]", "[data-validation-command-value]"), value || ""]); + return; + } + if (kind === "evidence") { + renderValidationEvidenceRows([...collectValidationRowValues("[data-validation-evidence-row]", "[data-validation-evidence-value]"), value || ""]); + return; + } + if (kind === "gates") { + renderValidationGateRows([...collectValidationRowValues("[data-validation-gate-row]", "[data-validation-gate-value]"), value || ""]); + return; + } + renderValidationSchemaRows([...collectValidationRowValues("[data-validation-schema-row]", "[data-validation-schema-value]"), value || ""]); +} + +function integrationValidationContext(steps = []) { + const selectedSteps = Array.isArray(steps) ? steps : []; + const receipts = selectedSteps.map((step) => step.receipt || step.path || "").filter(Boolean); + const artifacts = selectedSteps.flatMap((step) => Array.isArray(step.artifacts) ? step.artifacts : []); + const dependencies = selectedSteps.flatMap((step) => Array.isArray(step.dependencies) ? step.dependencies : []); + const gates = selectedSteps.flatMap((step) => Array.isArray(step.gates) ? step.gates : []); + return { + commands: ["python3 -m json.tool workspace/runs/dev-pipeline-studio/docs-pages/latest/integration/integration_lane.json"], + evidence: uniquePipelineLines(["integration/integration_lane.json", ...receipts, ...artifacts]), + gates: uniquePipelineLines([ + ...gates, + ...dependencies.map((dependency) => `Dependency receipt accepted: ${dependency}`), + "Integration lane has no blocked or rejected receipts" + ]), + schema: uniquePipelineLines(["integration/integration_lane.json", "integration_receipts/*.json"]) + }; +} + +function importPipelineIntegrationContext(steps) { + const context = integrationValidationContext(steps); + appendPipelineTextareaLines(pipelineValidationCommandsInput, context.commands); + appendPipelineTextareaLines(pipelineValidationEvidenceInput, context.evidence); + appendPipelineTextareaLines(pipelineValidationGatesInput, context.gates); + appendPipelineTextareaLines(pipelineValidationSchemaInput, context.schema); + renderPipelineValidationTypedEditors({ + commands: pipelineTextToLines(pipelineValidationCommandsInput?.value || ""), + evidence: pipelineTextToLines(pipelineValidationEvidenceInput?.value || ""), + gates: pipelineTextToLines(pipelineValidationGatesInput?.value || ""), + schema_paths: pipelineTextToLines(pipelineValidationSchemaInput?.value || "") + }); + updatePipelineValidationModeButtons(pipelineValidationModeSelect?.value || "commands"); + if (pipelineValidationInspectorStatus) { + pipelineValidationInspectorStatus.textContent = `Imported ${steps.length} integration step${steps.length === 1 ? "" : "s"} into this validator. Save validation to write receipts.`; + } +} + +function renderPipelineValidationConsultation() { + if (!pipelineValidationIntegrationContext) return; + const integrations = pipelineStudioState?.pipeline?.integration || []; + if (!integrations.length) { + pipelineValidationIntegrationContext.innerHTML = `

    No integration steps are configured for this template yet.

    `; + return; + } + pipelineValidationIntegrationContext.innerHTML = integrations.map((step, index) => ` +
    +
    + ${escapeHtml(step.title || step.id || `Integration ${index + 1}`)} + ${escapeHtml(step.status || "Configured")} · ${escapeHtml(step.mode || "dependency-order")} +
    + ${escapeHtml((step.artifacts || []).slice(0, 2).join(", ") || step.receipt || "artifact pending")} + +
    + `).join(""); +} + +function setPipelineInspectorMode(mode) { + const inputMode = mode === "input"; + const integrationMode = mode === "integration"; + const validationMode = mode === "validation"; + const evidenceMode = mode === "evidence"; + const workerMode = !inputMode && !integrationMode && !validationMode && !evidenceMode; + pipelineInputInspector?.classList.toggle("hidden", !inputMode); + pipelineIntegrationInspector?.classList.toggle("hidden", !integrationMode); + pipelineValidationInspector?.classList.toggle("hidden", !validationMode); + pipelineEvidenceInspector?.classList.toggle("hidden", !evidenceMode); + pipelineInspectorNav?.classList.toggle("hidden", !workerMode); + if (workerMode) { + setInspectorTab(currentInspectorTab); + } else { + pipelineWorkerInspectorActions?.classList.add("hidden"); + pipelineManifestEditor?.classList.add("hidden"); + pipelineContractSummary?.classList.add("hidden"); + pipelineContractPanel?.classList.add("hidden"); + pipelineArtifactPanel?.classList.add("hidden"); + pipelineLogsPanel?.classList.add("hidden"); + pipelineCostPanel?.classList.add("hidden"); + } +} + +function setInspectorTab(tabName) { + currentInspectorTab = tabName || "manifest"; + const isManifest = currentInspectorTab === "manifest"; + const isContract = currentInspectorTab === "contract"; + const isArtifact = currentInspectorTab === "artifact"; + const isLogs = currentInspectorTab === "logs"; + const isCost = currentInspectorTab === "cost"; + pipelineInspectorNav?.querySelectorAll("a[data-inspector-tab]").forEach((link) => { + link.classList.toggle("active", link.dataset.inspectorTab === currentInspectorTab); + }); + pipelineWorkerInspectorActions?.classList.toggle("hidden", !isManifest); + pipelineManifestEditor?.classList.toggle("hidden", !isManifest); + pipelineContractSummary?.classList.toggle("hidden", !isManifest); + pipelineContractPanel?.classList.toggle("hidden", !isContract); + pipelineArtifactPanel?.classList.toggle("hidden", !isArtifact); + pipelineLogsPanel?.classList.toggle("hidden", !isLogs); + pipelineCostPanel?.classList.toggle("hidden", !isCost); + if (isContract) populateContractPanel(); +} + +function populateContractPanel() { + let manifest = null; + try { manifest = parsePipelineManifestEditor(); } catch { manifest = {}; } + const ownedPaths = manifest?.owned_paths || []; + const readPaths = manifest?.read_paths || []; + const dependencies = manifest?.dependencies || []; + const acceptance = manifest?.acceptance || []; + const tier = manifest?.validation?.tier || manifest?.validation_tier || "—"; + const risk = manifest?.validation?.risk || manifest?.risk || "—"; + const noneItem = '
  • None
  • '; + const pathItem = (p) => `
  • ${escapeHtml(String(p))}
  • `; + const acceptItem = (a) => `
  • ${escapeHtml(String(a))}
  • `; + const owned = document.querySelector("#contractOwnedPaths"); + const read = document.querySelector("#contractReadPaths"); + const deps = document.querySelector("#contractDependencies"); + const acc = document.querySelector("#contractAcceptance"); + const tierEl = document.querySelector("#contractValidationTier"); + const riskEl = document.querySelector("#contractRiskLevel"); + if (owned) owned.innerHTML = ownedPaths.length ? ownedPaths.map(pathItem).join("") : noneItem; + if (read) read.innerHTML = readPaths.length ? readPaths.map(pathItem).join("") : noneItem; + if (deps) deps.innerHTML = dependencies.length ? dependencies.map(pathItem).join("") : noneItem; + if (acc) acc.innerHTML = acceptance.length ? acceptance.map(acceptItem).join("") : noneItem; + if (tierEl) tierEl.textContent = tier; + if (riskEl) riskEl.textContent = risk; +} + +function titleCasePipelineStatus(status) { + const raw = String(status || "").trim(); + if (!raw) return "Input"; + return raw.charAt(0).toUpperCase() + raw.slice(1); +} + +function showPipelineWorkerInspector() { + currentInspectorTab = "manifest"; + setPipelineInspectorMode("worker"); + pipelineSelectedInputId = ""; + pipelineSelectedIntegrationId = ""; + pipelineSelectedValidationId = ""; + pipelineSelectedEvidenceId = ""; + if (pipelineInspectorBadge) pipelineInspectorBadge.textContent = "W1"; + if (pipelineInspectorState) pipelineInspectorState.textContent = "Completed"; + renderPipelineInputCards(pipelineStudioState?.pipeline?.input_cards || selectedPipelinePayloadTemplate()?.required_inputs || selectedPipelineStudioTemplate()?.requiredInputs || []); + renderPipelineIntegrationCards(pipelineStudioState?.pipeline?.integration || []); + renderPipelineValidatorCards(pipelineStudioState?.pipeline?.validators || selectedPipelinePayloadTemplate()?.validators || []); + renderPipelineEvidenceCards(pipelineStudioState?.pipeline?.evidence || []); +} + +function updatePipelineInputTypeEditors(type) { + const activeType = pipelineInputType({ kind: type }); + document.querySelectorAll("[data-input-type-editor]").forEach((editor) => { + const supported = String(editor.getAttribute("data-input-type-editor") || "").split(/\s+/); + editor.classList.toggle("hidden", !supported.includes(activeType)); + }); +} + +function showPipelineInputInspector(inputId, message = "") { + const input = selectedPipelineInput(inputId); + if (!input) { + pipelineSelectedInputId = ""; + showPipelineWorkerInspector(); + return; + } + const status = String(input.status || "missing").toLowerCase(); + pipelineSelectedInputId = inputId; + pipelineSelectedIntegrationId = ""; + pipelineSelectedValidationId = ""; + pipelineSelectedEvidenceId = ""; + setPipelineInspectorMode("input"); + setPipelineField("selectedWorker", input.title || "Input"); + if (pipelineInspectorBadge) pipelineInspectorBadge.textContent = "Input"; + if (pipelineInspectorState) pipelineInspectorState.textContent = titleCasePipelineStatus(status); + if (pipelineInputTitleInput) pipelineInputTitleInput.value = input.title || ""; + if (pipelineInputTypeSelect) pipelineInputTypeSelect.value = pipelineInputType(input); + if (pipelineInputSourceSelect) pipelineInputSourceSelect.value = pipelineInputSource(input); + if (pipelineInputDetailInput) pipelineInputDetailInput.value = input.detail || input.file || ""; + if (pipelineInputStatusSelect) { + pipelineInputStatusSelect.value = status; + pipelineInputStatusSelect.dataset.initialStatus = status; + } + if (pipelineInputAutomationInput) pipelineInputAutomationInput.value = pipelineInputAutomation(input); + if (pipelineInputRequiredCheckbox) pipelineInputRequiredCheckbox.checked = input.required !== false; + if (pipelineInputMutedCheckbox) pipelineInputMutedCheckbox.checked = Boolean(input.muted || status === "muted" || input.blocking === false); + if (pipelineInputFormatInput) pipelineInputFormatInput.value = input.format || PIPELINE_INPUT_TYPES[pipelineInputType(input)]?.format || ""; + if (pipelineInputImageRefsInput) pipelineInputImageRefsInput.value = pipelineLinesToText(input.image_refs || input.images || input.references); + if (pipelineInputImageNotesInput) pipelineInputImageNotesInput.value = input.image_notes || input.reference_notes || ""; + if (pipelineInputQuestionsInput) pipelineInputQuestionsInput.value = pipelineQuestionItemsToText(input.questions || input.questionnaire); + if (pipelineInputPathsInput) pipelineInputPathsInput.value = pipelineLinesToText(input.paths || input.target_paths || input.routes); + if (pipelineInputPathPolicyInput) pipelineInputPathPolicyInput.value = input.path_policy || input.ownership_policy || ""; + if (pipelineInputArtifactsInput) pipelineInputArtifactsInput.value = pipelineLinesToText(input.artifacts || input.evidence_artifacts); + if (pipelineInputEvidencePolicyInput) pipelineInputEvidencePolicyInput.value = input.evidence_policy || input.validation_policy || ""; + if (pipelineInputAnswerInput) pipelineInputAnswerInput.value = input.answer || input.provided_answer || ""; + if (pipelineInputAnswerValuesInput) pipelineInputAnswerValuesInput.value = pipelineLinesToText(input.answer_values || input.provided_values || input.provided_paths); + if (pipelineInputAnswerNotesInput) pipelineInputAnswerNotesInput.value = input.answer_notes || input.provided_notes || ""; + if (pipelineInputAnswerState) { + pipelineInputAnswerState.textContent = pipelineInputAnswerPresent(input) + ? `Answer saved${input.provided_at ? ` at ${new Date(input.provided_at).toLocaleTimeString()}` : ""}` + : "No answer saved yet"; + } + if (pipelineInputManifestPath) pipelineInputManifestPath.textContent = input.manifest ? `Input manifest: ${input.manifest}` : "Input manifest output pending"; + updatePipelineInputTypeEditors(pipelineInputType(input)); + renderPipelineImagePreviews(pipelineInputImagePreview, [ + ...pipelineValueList(input.image_refs || input.images || input.references), + ...pipelineValueList(input.artifacts || input.evidence_artifacts), + ...pipelineValueList(input.answer_values || input.provided_values || input.provided_paths), + ]); + if (pipelineInputInspectorStatus) pipelineInputInspectorStatus.textContent = message || "Edit the input contract or provide run configuration, then save."; + renderPipelineInputCards(pipelineStudioState?.pipeline?.input_cards || selectedPipelinePayloadTemplate()?.required_inputs || selectedPipelineStudioTemplate()?.requiredInputs || []); + renderPipelineIntegrationCards(pipelineStudioState?.pipeline?.integration || []); + renderPipelineValidatorCards(pipelineStudioState?.pipeline?.validators || selectedPipelinePayloadTemplate()?.validators || []); + renderPipelineEvidenceCards(pipelineStudioState?.pipeline?.evidence || []); + syncPipelineWorkerCards(pipelineStudioState?.pipeline?.workers || selectedPipelineStudioTemplate()?.workers || []); +} + +function collectPipelineInputConfig() { + const selected = selectedPipelineInput() || {}; + const kind = pipelineInputType({ kind: pipelineInputTypeSelect?.value || selected.kind || selected.input_type || selected.type }); + const answer = pipelineInputAnswerInput?.value?.trim() || ""; + const answerValues = pipelineTextToLines(pipelineInputAnswerValuesInput?.value || ""); + const answerNotes = pipelineInputAnswerNotesInput?.value?.trim() || ""; + const answerPresent = Boolean(answer || answerValues.length || answerNotes); + const selectedStatus = pipelineInputStatusSelect?.value || "missing"; + const initialStatus = pipelineInputStatusSelect?.dataset.initialStatus || selected.status || "missing"; + const status = answerPresent && selectedStatus === initialStatus && selectedStatus !== "optional" + ? "provided" + : selectedStatus; + return { + ...selected, + id: pipelineSelectedInputId, + title: pipelineInputTitleInput?.value?.trim() || selected.title || "Untitled input", + detail: pipelineInputDetailInput?.value?.trim() || "", + kind, + input_type: kind, + source: pipelineInputSourceSelect?.value || selected.source || "user", + automation: pipelineInputAutomationInput?.value?.trim() || selected.automation || selected.automation_source || "", + automation_source: pipelineInputAutomationInput?.value?.trim() || selected.automation_source || selected.automation || "", + muted: Boolean(pipelineInputMutedCheckbox?.checked), + blocking: !Boolean(pipelineInputMutedCheckbox?.checked), + status, + required: Boolean(pipelineInputRequiredCheckbox?.checked), + format: pipelineInputFormatInput?.value?.trim() || PIPELINE_INPUT_TYPES[kind]?.format || "", + image_refs: pipelineTextToLines(pipelineInputImageRefsInput?.value || ""), + image_notes: pipelineInputImageNotesInput?.value?.trim() || "", + questions: pipelineTextToQuestionItems(pipelineInputQuestionsInput?.value || ""), + paths: pipelineTextToLines(pipelineInputPathsInput?.value || ""), + path_policy: pipelineInputPathPolicyInput?.value?.trim() || "", + artifacts: pipelineTextToLines(pipelineInputArtifactsInput?.value || ""), + evidence_policy: pipelineInputEvidencePolicyInput?.value?.trim() || "", + answer, + answer_values: answerValues, + answer_notes: answerNotes, + answer_present: answerPresent, + provided_at: answerPresent ? selected.provided_at || new Date().toISOString() : "", + manifest: selected.manifest || "" + }; +} + +async function savePipelineSelectedInput() { + if (!pipelineSelectedInputId) return; + const values = collectPipelineInputConfig(); + if (pipelineInputInspectorStatus) pipelineInputInspectorStatus.textContent = "Saving input..."; + const payload = await savePipelineDraft("save_input", { includeManifest: false, inputConfig: values }); + if (payload) { + pipelineSelectedInputId = values.id || pipelineSelectedInputId; + showPipelineInputInspector(pipelineSelectedInputId, `Saved ${values.title}.`); + } +} + +function updatePipelineValidationModeButtons(mode) { + document.querySelectorAll("[data-validation-mode]").forEach((button) => { + const active = button.dataset.validationMode === mode; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", String(active)); + }); + document.querySelectorAll("[data-validation-editor]").forEach((editor) => { + editor.classList.toggle("active", editor.dataset.validationEditor === mode); + }); + if (pipelineValidationRunButton) { + pipelineValidationRunButton.textContent = `Run ${validationModeLabel(mode)}`; + } + const validator = selectedPipelineValidator(); + if (validator) renderPipelineValidationRunResults(validator, mode); +} + +function validationModeLabel(mode = "commands") { + const labels = { + commands: "commands", + evidence: "evidence", + gates: "gates", + schema: "schema" + }; + return labels[mode] || "validation"; +} + +function validationResultLabel(item = {}) { + return item.command || item.path || item.gate || item.resolved_path || item.id || "validation result"; +} + +function renderPipelineValidationRunResults(validator = {}, mode = "commands") { + if (!pipelineValidationRunResults) return; + const results = validator.results && typeof validator.results === "object" ? validator.results : {}; + const items = Array.isArray(results[mode]) ? results[mode] : []; + const status = validator.last_run_status || validator.status || "configured"; + if (pipelineValidationRunStatus) { + const activeStatus = items.length ? resultCollectionStatus(items) : ""; + const lastMode = validator.last_run_mode ? validationModeLabel(validator.last_run_mode) : ""; + const when = validator.executed_at ? ` at ${new Date(validator.executed_at).toLocaleTimeString()}` : ""; + pipelineValidationRunStatus.textContent = items.length + ? `${validationModeLabel(mode)} ${activeStatus}${when}` + : validator.executed_at + ? `Last run: ${lastMode} ${status}${when}` + : "No execution yet"; + } + if (!items.length) { + pipelineValidationRunResults.innerHTML = ` +
    ${escapeHtml(validationModeLabel(mode))} resultsRun this tab to write execution results.
    +

    No ${escapeHtml(validationModeLabel(mode))} results recorded yet.

    + `; + return; + } + pipelineValidationRunResults.innerHTML = ` +
    ${escapeHtml(validationModeLabel(mode))} results${items.length} check${items.length === 1 ? "" : "s"} recorded
    +
    + ${items.map((item) => { + const resultStatus = String(item.status || "configured").toLowerCase(); + const details = item.details || (typeof item.returncode !== "undefined" ? `exit ${item.returncode}` : ""); + return ` +
    + ${escapeHtml(resultStatus)} + ${escapeHtml(validationResultLabel(item))} + ${escapeHtml(details)} +
    + `; + }).join("")} +
    + `; +} + +function resultCollectionStatus(items) { + const statuses = new Set((items || []).map((item) => String(item.status || "").toLowerCase()).filter(Boolean)); + if (!statuses.size) return "not run"; + if (statuses.has("failed")) return "failed"; + if (statuses.has("warning")) return "warning"; + if ([...statuses].every((status) => status === "passed" || status === "accepted")) return "passed"; + return "recorded"; +} + +function updatePipelineIntegrationView(view = pipelineIntegrationActiveView) { + const nextView = ["order", "apply", "conflicts", "receipts"].includes(view) ? view : "order"; + pipelineIntegrationActiveView = nextView; + document.querySelectorAll("[data-integration-view]").forEach((button) => { + const active = button.dataset.integrationView === nextView; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", String(active)); + }); + document.querySelectorAll("[data-integration-section]").forEach((section) => { + const views = String(section.dataset.integrationSection || "").split(/\s+/).filter(Boolean); + section.classList.toggle("hidden", !views.includes(nextView)); + }); +} + +function showPipelineIntegrationInspector(integrationId, message = "") { + const integration = selectedPipelineIntegration(integrationId); + if (!integration) { + pipelineSelectedIntegrationId = ""; + showPipelineWorkerInspector(); + return; + } + const status = String(integration.status || "configured").toLowerCase().replace(/\s+/g, "-"); + const mode = String(integration.mode || "dependency-order").toLowerCase(); + pipelineSelectedIntegrationId = integrationId; + pipelineSelectedInputId = ""; + pipelineSelectedValidationId = ""; + pipelineSelectedEvidenceId = ""; + setPipelineInspectorMode("integration"); + setPipelineField("selectedWorker", integration.title || "Integration"); + if (pipelineInspectorBadge) pipelineInspectorBadge.textContent = "Integrator"; + if (pipelineInspectorState) pipelineInspectorState.textContent = titleCasePipelineStatus(status); + if (pipelineIntegrationTitleInput) pipelineIntegrationTitleInput.value = integration.title || ""; + if (pipelineIntegrationStatusSelect) pipelineIntegrationStatusSelect.value = status; + if (pipelineIntegrationModeSelect) pipelineIntegrationModeSelect.value = mode; + if (pipelineIntegrationApplyInput) pipelineIntegrationApplyInput.value = integration.apply_policy || integration.summary || ""; + if (pipelineIntegrationConflictInput) pipelineIntegrationConflictInput.value = integration.conflict_policy || ""; + if (pipelineIntegrationDependenciesInput) pipelineIntegrationDependenciesInput.value = pipelineLinesToText(integration.dependencies); + if (pipelineIntegrationArtifactsInput) pipelineIntegrationArtifactsInput.value = pipelineLinesToText(integration.artifacts); + if (pipelineIntegrationGatesInput) pipelineIntegrationGatesInput.value = pipelineLinesToText(integration.gates); + if (pipelineIntegrationRollbackInput) pipelineIntegrationRollbackInput.value = pipelineLinesToText(integration.rollback_plan); + if (pipelineIntegrationConfigPath) pipelineIntegrationConfigPath.textContent = integration.config ? `Config: ${integration.config}` : "Config output pending"; + if (pipelineIntegrationReceiptPath) pipelineIntegrationReceiptPath.textContent = integration.receipt ? `Receipt: ${integration.receipt}` : integration.path ? `Receipt: ${integration.path}` : "Receipt output pending"; + if (pipelineIntegrationInspectorStatus) pipelineIntegrationInspectorStatus.textContent = message || "Configure this integration lane view, then save outputs."; + updatePipelineIntegrationView(pipelineIntegrationActiveView); + renderPipelineInputCards(pipelineStudioState?.pipeline?.input_cards || selectedPipelinePayloadTemplate()?.required_inputs || selectedPipelineStudioTemplate()?.requiredInputs || []); + renderPipelineIntegrationCards(pipelineStudioState?.pipeline?.integration || []); + renderPipelineValidatorCards(pipelineStudioState?.pipeline?.validators || selectedPipelinePayloadTemplate()?.validators || []); + renderPipelineEvidenceCards(pipelineStudioState?.pipeline?.evidence || []); + syncPipelineWorkerCards(pipelineStudioState?.pipeline?.workers || selectedPipelineStudioTemplate()?.workers || []); +} + +function collectPipelineIntegrationConfig() { + const selected = selectedPipelineIntegration(); + return { + id: pipelineSelectedIntegrationId, + title: pipelineIntegrationTitleInput?.value?.trim() || selected?.title || "Integration step", + status: pipelineIntegrationStatusSelect?.value || "configured", + mode: pipelineIntegrationModeSelect?.value || "dependency-order", + apply_policy: pipelineIntegrationApplyInput?.value?.trim() || "", + conflict_policy: pipelineIntegrationConflictInput?.value?.trim() || "", + dependencies: pipelineTextToLines(pipelineIntegrationDependenciesInput?.value || ""), + artifacts: pipelineTextToLines(pipelineIntegrationArtifactsInput?.value || ""), + gates: pipelineTextToLines(pipelineIntegrationGatesInput?.value || ""), + rollback_plan: pipelineTextToLines(pipelineIntegrationRollbackInput?.value || ""), + receipt: selected?.receipt || "", + config_path: selected?.config?.replace(/^.*workspace\/runs\/dev-pipeline-studio\/docs-pages\/latest\//, "") || "" + }; +} + +async function savePipelineSelectedIntegration() { + if (!pipelineSelectedIntegrationId) return; + const integrationConfig = collectPipelineIntegrationConfig(); + if (pipelineIntegrationInspectorStatus) pipelineIntegrationInspectorStatus.textContent = "Saving integration outputs..."; + const payload = await savePipelineDraft("save_integration", { includeManifest: false, integrationConfig }); + if (payload) { + showPipelineIntegrationInspector(pipelineSelectedIntegrationId, `Saved ${integrationConfig.title}.`); + } +} + +function showPipelineValidationInspector(validatorId, message = "") { + const validator = selectedPipelineValidator(validatorId); + if (!validator) { + pipelineSelectedValidationId = ""; + showPipelineWorkerInspector(); + return; + } + const status = String(validator.status || "configured").toLowerCase().replace(/\s+/g, "-"); + const mode = String(validator.mode || "commands").toLowerCase(); + pipelineSelectedValidationId = validatorId; + pipelineSelectedInputId = ""; + pipelineSelectedIntegrationId = ""; + pipelineSelectedEvidenceId = ""; + setPipelineInspectorMode("validation"); + setPipelineField("selectedWorker", validator.title || "Validation"); + if (pipelineInspectorBadge) pipelineInspectorBadge.textContent = "Validator"; + if (pipelineInspectorState) pipelineInspectorState.textContent = titleCasePipelineStatus(status); + if (pipelineValidationTitleInput) pipelineValidationTitleInput.value = validator.title || ""; + if (pipelineValidationStatusSelect) pipelineValidationStatusSelect.value = status; + if (pipelineValidationTierSelect) pipelineValidationTierSelect.value = validator.tier || pipelineStudioState?.pipeline?.validation?.tier || "smoke-plus"; + if (pipelineValidationModeSelect) pipelineValidationModeSelect.value = mode; + if (pipelineValidationSummaryInput) pipelineValidationSummaryInput.value = validator.summary || ""; + if (pipelineValidationCommandsInput) pipelineValidationCommandsInput.value = pipelineLinesToText(validator.commands); + if (pipelineValidationEvidenceInput) pipelineValidationEvidenceInput.value = pipelineLinesToText(validator.evidence || validator.path || validator.receipt); + if (pipelineValidationGatesInput) pipelineValidationGatesInput.value = pipelineLinesToText(validator.gates); + if (pipelineValidationSchemaInput) pipelineValidationSchemaInput.value = pipelineLinesToText(validator.schema_paths); + if (pipelineValidationBlockingCheckbox) pipelineValidationBlockingCheckbox.checked = validator.blocking !== false; + if (pipelineValidationConfigPath) pipelineValidationConfigPath.textContent = validator.config ? `Config: ${validator.config}` : "Config output pending"; + if (pipelineValidationReceiptPath) pipelineValidationReceiptPath.textContent = validator.receipt ? `Receipt: ${validator.receipt}` : validator.path ? `Receipt: ${validator.path}` : "Receipt output pending"; + if (pipelineValidationInspectorStatus) pipelineValidationInspectorStatus.textContent = message || "Configure the validation lane, then save outputs."; + renderPipelineValidationTypedEditors(validator); + renderPipelineValidationConsultation(); + updatePipelineValidationModeButtons(mode); + renderPipelineInputCards(pipelineStudioState?.pipeline?.input_cards || selectedPipelinePayloadTemplate()?.required_inputs || selectedPipelineStudioTemplate()?.requiredInputs || []); + renderPipelineIntegrationCards(pipelineStudioState?.pipeline?.integration || []); + renderPipelineValidatorCards(pipelineStudioState?.pipeline?.validators || selectedPipelinePayloadTemplate()?.validators || []); + renderPipelineEvidenceCards(pipelineStudioState?.pipeline?.evidence || []); + syncPipelineWorkerCards(pipelineStudioState?.pipeline?.workers || selectedPipelineStudioTemplate()?.workers || []); +} + +function collectPipelineValidationConfig() { + const selected = selectedPipelineValidator(); + syncValidationRowsToTextareas(); + ensureValidationIntegrationGate(); + return { + id: pipelineSelectedValidationId, + title: pipelineValidationTitleInput?.value?.trim() || selected?.title || "Validator", + status: pipelineValidationStatusSelect?.value || "configured", + tier: pipelineValidationTierSelect?.value || pipelineStudioState?.pipeline?.validation?.tier || "smoke-plus", + mode: pipelineValidationModeSelect?.value || "commands", + summary: pipelineValidationSummaryInput?.value?.trim() || "", + commands: pipelineTextToLines(pipelineValidationCommandsInput?.value || ""), + evidence: pipelineTextToLines(pipelineValidationEvidenceInput?.value || ""), + gates: pipelineTextToLines(pipelineValidationGatesInput?.value || ""), + schema_paths: pipelineTextToLines(pipelineValidationSchemaInput?.value || ""), + blocking: Boolean(pipelineValidationBlockingCheckbox?.checked), + receipt: selected?.receipt || "", + config_path: selected?.config?.replace(/^.*workspace\/runs\/dev-pipeline-studio\/docs-pages\/latest\//, "") || "" + }; +} + +async function savePipelineSelectedValidation() { + if (!pipelineSelectedValidationId) return; + const validationConfig = collectPipelineValidationConfig(); + if (pipelineValidationInspectorStatus) pipelineValidationInspectorStatus.textContent = "Saving validation outputs..."; + const payload = await savePipelineDraft("save_validation", { includeManifest: false, validationConfig }); + if (payload) { + showPipelineValidationInspector(pipelineSelectedValidationId, `Saved ${validationConfig.title}.`); + } +} + +async function runPipelineSelectedValidation() { + if (!pipelineSelectedValidationId) return; + const validationConfig = collectPipelineValidationConfig(); + const validationRunMode = pipelineValidationModeSelect?.value || validationConfig.mode || "commands"; + if (pipelineValidationInspectorStatus) pipelineValidationInspectorStatus.textContent = `Running ${validationModeLabel(validationRunMode)} validation...`; + if (pipelineValidationRunStatus) pipelineValidationRunStatus.textContent = `Running ${validationModeLabel(validationRunMode)}...`; + const payload = await savePipelineDraft("run_validation", { includeManifest: false, validationConfig, validationRunMode }); + if (payload) { + showPipelineValidationInspector(pipelineSelectedValidationId, `Ran ${validationModeLabel(validationRunMode)} for ${validationConfig.title}.`); + } +} + +function showPipelineEvidenceInspector(evidenceId, message = "") { + const evidence = selectedPipelineEvidence(evidenceId); + if (!evidence) { + pipelineSelectedEvidenceId = ""; + showPipelineWorkerInspector(); + return; + } + const status = String(evidence.state || evidence.status || "configured").toLowerCase().replace(/\s+/g, "-"); + pipelineSelectedEvidenceId = evidenceId; + pipelineSelectedInputId = ""; + pipelineSelectedIntegrationId = ""; + pipelineSelectedValidationId = ""; + setPipelineInspectorMode("evidence"); + setPipelineField("selectedWorker", evidence.title || "Evidence"); + if (pipelineInspectorBadge) pipelineInspectorBadge.textContent = "Evidence"; + if (pipelineInspectorState) pipelineInspectorState.textContent = titleCasePipelineStatus(status); + if (pipelineEvidenceTitleInput) pipelineEvidenceTitleInput.value = evidence.title || ""; + if (pipelineEvidenceStatusSelect) pipelineEvidenceStatusSelect.value = status; + if (pipelineEvidenceKindSelect) pipelineEvidenceKindSelect.value = evidence.kind || "artifact"; + if (pipelineEvidencePathInput) pipelineEvidencePathInput.value = evidence.path || ""; + if (pipelineEvidenceSourcesInput) pipelineEvidenceSourcesInput.value = pipelineLinesToText(evidence.required_sources); + if (pipelineEvidencePublishInput) pipelineEvidencePublishInput.value = evidence.publish_policy || ""; + if (pipelineEvidenceRetentionInput) pipelineEvidenceRetentionInput.value = evidence.retention_policy || ""; + if (pipelineEvidenceNotesInput) pipelineEvidenceNotesInput.value = evidence.review_notes || ""; + if (pipelineEvidenceConfigPath) pipelineEvidenceConfigPath.textContent = evidence.config ? `Config: ${evidence.config}` : "Config output pending"; + if (pipelineEvidenceArtifactPath) pipelineEvidenceArtifactPath.textContent = evidence.path ? `Artifact: ${evidence.path}` : "Artifact output pending"; + renderPipelineImagePreviews(pipelineEvidenceArtifactPreview, [ + evidence.path || "", + ...pipelineValueList(evidence.required_sources), + ]); + if (pipelineEvidenceInspectorStatus) pipelineEvidenceInspectorStatus.textContent = message || "Choose and configure this evidence artifact, then save outputs."; + renderPipelineInputCards(pipelineStudioState?.pipeline?.input_cards || selectedPipelinePayloadTemplate()?.required_inputs || selectedPipelineStudioTemplate()?.requiredInputs || []); + renderPipelineIntegrationCards(pipelineStudioState?.pipeline?.integration || []); + renderPipelineValidatorCards(pipelineStudioState?.pipeline?.validators || selectedPipelinePayloadTemplate()?.validators || []); + renderPipelineEvidenceCards(pipelineStudioState?.pipeline?.evidence || []); + syncPipelineWorkerCards(pipelineStudioState?.pipeline?.workers || selectedPipelineStudioTemplate()?.workers || []); +} + +function collectPipelineEvidenceConfig() { + const selected = selectedPipelineEvidence(); + return { + id: pipelineSelectedEvidenceId, + title: pipelineEvidenceTitleInput?.value?.trim() || selected?.title || "Evidence artifact", + status: pipelineEvidenceStatusSelect?.value || selected?.state || "configured", + kind: pipelineEvidenceKindSelect?.value || selected?.kind || "artifact", + path: pipelineEvidencePathInput?.value?.trim() || selected?.path || "", + required_sources: pipelineTextToLines(pipelineEvidenceSourcesInput?.value || ""), + publish_policy: pipelineEvidencePublishInput?.value?.trim() || "", + retention_policy: pipelineEvidenceRetentionInput?.value?.trim() || "", + review_notes: pipelineEvidenceNotesInput?.value?.trim() || "", + config_path: selected?.config?.replace(/^.*workspace\/runs\/dev-pipeline-studio\/docs-pages\/latest\//, "") || "" + }; +} + +async function savePipelineSelectedEvidence() { + if (!pipelineSelectedEvidenceId) return; + const evidenceConfig = collectPipelineEvidenceConfig(); + if (pipelineEvidenceInspectorStatus) pipelineEvidenceInspectorStatus.textContent = "Saving evidence outputs..."; + const payload = await savePipelineDraft("save_evidence", { includeManifest: false, evidenceConfig }); + if (payload) { + showPipelineEvidenceInspector(pipelineSelectedEvidenceId, `Saved ${evidenceConfig.title}.`); + } +} + +function populatePipelineEditor() { + const project = selectedPipelinePayloadProject(); + const template = selectedPipelinePayloadTemplate(); + const inspector = pipelineStudioState?.pipeline?.inspector || {}; + if (pipelineProjectLabelInput) pipelineProjectLabelInput.value = project?.label || ""; + if (pipelineTemplateLabelInput) pipelineTemplateLabelInput.value = template?.label || ""; + if (pipelineTemplateDetailInput) pipelineTemplateDetailInput.value = template?.detail || ""; + if (pipelineExecutionModelSelect) pipelineExecutionModelSelect.value = template?.execution_model || pipelineStudioState?.pipeline?.execution_model || "ordered"; + if (pipelineValidationTierInput) pipelineValidationTierInput.value = template?.validation_tier || pipelineStudioState?.pipeline?.validation?.tier || ""; + if (pipelineRiskSelect) pipelineRiskSelect.value = template?.risk || inspector.summary?.risk_level || "medium"; + if (pipelineBudgetCapInput) pipelineBudgetCapInput.value = template?.budget_cap_usd ?? ""; + if (pipelineReadPathsInput) pipelineReadPathsInput.value = Array.isArray(project?.read_paths) ? project.read_paths.join("\n") : ""; + const manifestText = JSON.stringify(inspector.manifest || {}, null, 2); + if (pipelineManifestEditor) pipelineManifestEditor.value = manifestText; + if (pipelineManifestCode) pipelineManifestCode.textContent = manifestText; + setPipelineSaveStatus("Loaded from pipeline manifest"); + setPipelineManifestStatus(inspector.manifest_path ? `Editing ${inspector.manifest_path}` : "Worker manifest synthesized from template"); +} + +function syncPipelineWorkerCards(workers) { + const cards = document.querySelectorAll(".pipelineCard.worker"); + cards.forEach((card, index) => { + const worker = workers[index]; + if (!worker) { + card.removeAttribute("data-worker-id"); + card.classList.remove("selected"); + return; + } + card.dataset.workerId = worker.id || ""; + card.classList.toggle("selected", Boolean(worker.selected)); + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-pressed", String(Boolean(worker.selected))); + }); +} + +function parsePipelineManifestEditor() { + if (!pipelineManifestEditor) return null; + const raw = pipelineManifestEditor.value.trim(); + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch (error) { + setPipelineManifestStatus(`Invalid worker manifest JSON: ${error.message}`, true); + throw error; + } +} + +function pipelineEditorPayload(action = "save", options = {}) { + const project = selectedPipelinePayloadProject(); + const template = selectedPipelinePayloadTemplate(); + const includeManifest = options.includeManifest !== false; + const workerManifest = includeManifest ? parsePipelineManifestEditor() : null; + const selectedWorker = options.workerId || workerManifest?.task_id || template?.selected_worker || pipelineStudioState?.pipeline?.workers?.find((worker) => worker.selected)?.id || ""; + const selectedTemplateId = pipelineStudioState?.selected?.template_id || pipelineTemplateSelect?.value || ""; + const workerStageLabel = selectedTemplateId === "generic-task" + ? "2. Repo Discovery" + : selectedTemplateId === "hard-proreq-task" + ? "2. Cento Context" + : selectedTemplateId === "multipipeline-proreq-chain" + ? "2. Multipipeline Context" + : (pipelineExecutionModelSelect?.value || template?.execution_model) === "ordered" + ? "2. Task Execution" + : "2. Workers (Parallel)"; + const payload = { + action, + project_id: pipelineStudioState?.selected?.project_id || pipelineProjectSelect?.value || "", + template_id: pipelineStudioState?.selected?.template_id || pipelineTemplateSelect?.value || "", + worker_id: options.workerId || "", + project: { + label: pipelineProjectLabelInput?.value || project?.label || "", + surface: pipelineSurfaceSelect?.selectedOptions?.[0]?.textContent || project?.surface || "", + surface_value: pipelineSurfaceSelect?.value || project?.surface_value || "", + owned_root: project?.owned_root || "", + read_paths_text: pipelineReadPathsInput?.value || "" + }, + template: { + label: pipelineTemplateLabelInput?.value || template?.label || "", + detail: pipelineTemplateDetailInput?.value || template?.detail || "", + description: template?.description || "", + tagline: template?.tagline || "", + validation_tier: pipelineValidationTierInput?.value || template?.validation_tier || "", + risk: pipelineRiskSelect?.value || template?.risk || "", + budget_spent_usd: template?.budget_spent_usd ?? 0, + budget_cap_usd: pipelineBudgetCapInput?.value || template?.budget_cap_usd || 0, + execution_model: pipelineExecutionModelSelect?.value || template?.execution_model || "ordered", + worker_stage_label: workerStageLabel, + factory_stage_label: "4. Factory Execution", + selected_worker: selectedWorker + }, + worker_manifest: workerManifest + }; + if (options.validationConfig) payload.validation_config = options.validationConfig; + if (options.validationRunMode) payload.validation_run_mode = options.validationRunMode; + if (options.integrationConfig) payload.integration_config = options.integrationConfig; + if (options.evidenceConfig) payload.evidence_config = options.evidenceConfig; + if (options.inputConfig) payload.input_config = options.inputConfig; + if (options.elementType) payload.element_type = options.elementType; + if (options.elementId) payload.element_id = options.elementId; + if (options.elementStage) payload.element_stage = options.elementStage; + return payload; +} + +async function savePipelineDraft(action = "save", options = {}) { + if (!pipelineProjectSelect || !pipelineTemplateSelect) return null; + try { + setPipelineSaveStatus(action === "select_worker" ? "Selecting worker..." : action === "save_input" ? "Saving input contract..." : action === "save_integration" ? "Saving integration outputs..." : action === "save_validation" ? "Saving validation outputs..." : action === "run_validation" ? "Running validation..." : action === "run_delivery" || action === "run_execution_e2e" ? "Starting pipeline run..." : action === "save_evidence" ? "Saving evidence outputs..." : action === "add_element" ? "Adding pipeline element..." : action === "delete_element" ? "Removing pipeline element..." : "Saving pipeline draft..."); + const response = await fetch(`${API_BASE}/dev-pipeline-studio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(pipelineEditorPayload(action, options)) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + normalizePipelineState(payload); + applyPipelineStudioContext(); + const verb = action === "duplicate" ? "Duplicated" : action === "new" ? "Created" : action === "select_worker" ? "Selected" : action === "save_input" ? "Saved input" : action === "save_integration" ? "Saved integration" : action === "save_validation" ? "Saved validation" : action === "run_validation" ? "Ran validation" : action === "run_delivery" || action === "run_execution_e2e" ? "Started pipeline run" : action === "save_evidence" ? "Saved evidence" : action === "add_element" ? "Added element" : action === "delete_element" ? "Removed element" : "Saved"; + setPipelineSaveStatus(`${verb} ${payload.selected?.template_id || "pipeline"} at ${new Date().toLocaleTimeString()}`); + return payload; + } catch (error) { + setPipelineSaveStatus(`Save failed: ${error.message}`, true); + return null; + } +} + +async function savePipelineSelectedManifest() { + try { + parsePipelineManifestEditor(); + } catch (error) { + return; + } + setPipelineManifestStatus("Saving worker manifest..."); + const payload = await savePipelineDraft("save", { includeManifest: true }); + if (!payload) { + setPipelineManifestStatus("Worker manifest save failed", true); + return; + } + setInspectorTab("manifest"); + const manifestPath = payload?.pipeline?.inspector?.manifest_path || ""; + setPipelineManifestStatus(manifestPath ? `Saved ${manifestPath}` : "Worker manifest saved"); +} + +function openPipelineElementEditor(type, id) { + const elementType = String(type || ""); + const elementId = String(id || ""); + if (!elementId) return; + if (elementType === "input") { + showPipelineInputInspector(elementId); + return; + } + if (elementType === "integration") { + showPipelineIntegrationInspector(elementId); + return; + } + if (elementType === "validation") { + showPipelineValidationInspector(elementId); + return; + } + if (elementType === "evidence") { + showPipelineEvidenceInspector(elementId); + return; + } + if (elementType === "worker") { + pipelineSelectedInputId = ""; + pipelineSelectedIntegrationId = ""; + pipelineSelectedValidationId = ""; + pipelineSelectedEvidenceId = ""; + showPipelineWorkerInspector(); + void savePipelineDraft("select_worker", { workerId: elementId, includeManifest: false }); + } +} + +async function addPipelineStageElement(type, stage) { + const payload = await savePipelineDraft("add_element", { + includeManifest: false, + elementType: type, + elementStage: stage || type + }); + const mutation = payload?.mutation || {}; + const elementType = mutation.element_type || type; + const elementId = mutation.element_id || ""; + if (elementId) { + openPipelineElementEditor(elementType, elementId); + setPipelineSaveStatus(`Added ${mutation.title || elementId}`); + } +} + +async function deletePipelineStageElement(type, id) { + const elementType = String(type || ""); + const elementId = String(id || ""); + if (!elementType || !elementId) return; + const label = `${elementType} ${elementId}`; + if (!window.confirm(`Remove ${label} from this pipeline?`)) return; + if (pipelineSelectedInputId === elementId) pipelineSelectedInputId = ""; + if (pipelineSelectedIntegrationId === elementId) pipelineSelectedIntegrationId = ""; + if (pipelineSelectedValidationId === elementId) pipelineSelectedValidationId = ""; + if (pipelineSelectedEvidenceId === elementId) pipelineSelectedEvidenceId = ""; + const payload = await savePipelineDraft("delete_element", { + includeManifest: false, + elementType, + elementId + }); + if (payload) { + showPipelineWorkerInspector(); + setPipelineSaveStatus(`Removed ${label}`); + } +} + +function applyPipelineStudioContext() { + if (pipelineStudioState?.pipeline) { + const pipeline = pipelineStudioState.pipeline; + const inspector = pipeline.inspector || {}; + const summary = inspector.summary || {}; + setPipelineField("status", pipeline.status || "Unknown"); + setPipelineField("statusDetail", pipeline.status_detail || ""); + setPipelineField("project", pipeline.project || ""); + setPipelineField("surface", pipeline.surface || ""); + setPipelineField("template", pipeline.template || ""); + setPipelineField("templateDetail", pipeline.template_detail || ""); + setPipelineField("tasks", pipeline.tasks || ""); + setPipelineField("taskState", pipeline.task_state || ""); + setPipelineField("budget", pipeline.budget || ""); + setPipelineField("budgetDetail", pipeline.budget_detail || ""); + setPipelineField("runName", pipeline.run_name || ""); + setPipelineField("inputCount", pipeline.input_count || ""); + setPipelineField("workerStageLabel", pipeline.worker_stage_label || "2. Workers (Parallel)"); + setPipelineField("factoryStageLabel", pipeline.factory_stage_label || "4. Factory Execution"); + setPipelineField("workerCount", pipeline.worker_count || ""); + setPipelineField("integrationCount", pipeline.integration_count || ""); + setPipelineField("selectedWorker", inspector.selected_worker || ""); + setPipelineField("ownedPathCount", summary.owned_paths || ""); + setPipelineField("readPathCount", summary.read_paths || ""); + setPipelineField("validationTier", summary.validation_tier || pipeline.validation?.tier || ""); + setPipelineField("riskLevel", summary.risk_level || ""); + renderPipelineInputCards(pipeline.input_cards || []); + renderPipelineWorkerCards(pipeline.workers || []); + renderPipelineIntegrationCards(pipeline.integration || []); + renderPipelineValidatorCards(pipeline.validators || []); + renderPipelineEvidenceCards(pipeline.evidence || []); + renderPipelineCards("data-pipeline-input", pipeline.input_cards || [], [["title", "title"], ["file", "file"], ["status", "status"]]); + renderPipelineCards("data-pipeline-worker", pipeline.workers || [], [["title", "title"], ["file", "detail"]]); + renderPipelineCards("data-pipeline-integrate", pipeline.integration || [], [["title", "title"]]); + renderPipelineCards("data-pipeline-validator", pipeline.validators || [], [["title", "title"], ["file", "file"], ["status", "status"]]); + renderPipelineCards("data-pipeline-evidence", pipeline.evidence || [], [["title", "title"], ["file", "file"], ["status", "status"]]); + renderPipelineExecutionFlow(); + if (pipelineManifestCode) { + pipelineManifestCode.textContent = JSON.stringify(inspector.manifest || {}, null, 2); + } + populatePipelineEditor(); + syncPipelineWorkerCards(pipeline.workers || []); + if (pipelineSelectedIntegrationId) { + showPipelineIntegrationInspector(pipelineSelectedIntegrationId); + } else if (pipelineSelectedValidationId) { + showPipelineValidationInspector(pipelineSelectedValidationId); + } else if (pipelineSelectedEvidenceId) { + showPipelineEvidenceInspector(pipelineSelectedEvidenceId); + } else if (pipelineSelectedInputId) { + showPipelineInputInspector(pipelineSelectedInputId); + } else { + showPipelineWorkerInspector(); + } + pipelineTemplateCards.forEach((card) => { + const isActive = card.dataset.templateCard === (pipelineStudioState.selected?.template_id || ""); + card.classList.toggle("active", isActive); + card.setAttribute("aria-pressed", String(isActive)); + }); + if (manifestExplorerEl?.dataset.initialized) { + refreshManifestExplorer({ preserveSelection: true }); + } + return; + } + if (!pipelineProjectSelect || !pipelineTemplateSelect) return; + const project = selectedPipelineStudioProject(); + const template = selectedPipelineStudioTemplate(); + const selectedWorker = template.workers[template.selectedIndex] || template.workers[0]; + const workerFiles = template.workers.map((worker) => worker.file); + const runName = `${template.slug}-${project.key}_2026-05-02_120501`; + const readPaths = [...project.readPaths, `templates/pipelines/${template.id}.json`]; + const manifest = { + schema_version: "cento.worker_manifest.v1", + id: `${selectedWorker.id}_worker_01`, + project: project.key, + template_id: template.id, + type: template.workerType, + task_id: selectedWorker.id, + description: `${selectedWorker.description} for ${project.name} using the ${template.label} template`, + owned_paths: [`${project.ownedRoot}/${selectedWorker.file}`], + read_paths: readPaths, + dependencies: [], + acceptance: [ + `${template.label} output is valid`, + "Template parameters are preserved", + "Only owned paths changed" + ], + validation: { + tier: template.validationTier + } + }; + + if (pipelineSurfaceSelect) pipelineSurfaceSelect.value = project.surfaceValue; + setPipelineField("project", project.name); + setPipelineField("surface", project.surface); + setPipelineField("template", template.label); + setPipelineField("templateDetail", template.detail); + setPipelineField("tasks", template.tasks); + setPipelineField("taskState", "Template ready"); + setPipelineField("budget", template.budget); + setPipelineField("budgetDetail", template.budgetDetail); + setPipelineField("runName", runName); + setPipelineField("inputCount", `${(template.requiredInputs || []).length} inputs`); + setPipelineField("workerStageLabel", template.workerStageLabel || "2. Workers (Parallel)"); + setPipelineField("factoryStageLabel", "4. Factory Execution"); + setPipelineField("workerCount", `${template.workers.length} workers`); + const fallbackFactorySteps = template.factorySteps || [ + { id: "checkout", title: "checkout_branch", file: "execution_manifest.json", status: "Queued" }, + { id: "snapshot", title: "snapshot_repo_state", file: "repo_snapshot.json", status: "Queued" }, + { id: "apply", title: "apply_change_units", file: "factory_apply_receipt.json", status: "Queued" }, + { id: "focused-tests", title: "run_focused_tests", file: "focused_tests.log", status: "Queued" }, + { id: "collect", title: "collect_diff_and_logs", file: "evidence_manifest.json", status: "Queued" } + ]; + setPipelineField("integrationCount", `${fallbackFactorySteps.length} execution steps`); + setPipelineField("selectedWorker", selectedWorker.title); + setPipelineField("ownedPathCount", "1 path"); + setPipelineField("readPathCount", `${readPaths.length} paths`); + setPipelineField("validationTier", template.validationTier); + setPipelineField("riskLevel", template.risk); + updateIndexedPipelineText("data-pipeline-worker-title", template.workers.map((worker) => worker.title)); + updateIndexedPipelineText("data-pipeline-worker-file", workerFiles); + updateIndexedPipelineText("data-pipeline-integrate-title", workerFiles.map((file) => `Integrate: ${file}`)); + renderPipelineWorkerCards(template.workers || []); + renderPipelineIntegrationCards(fallbackFactorySteps.map((step) => ({ + id: step.id, + title: step.title, + file: step.file || "execution_receipt.json", + status: step.status || "Queued", + mode: "deterministic" + }))); + renderPipelineInputCards(template.requiredInputs || []); + renderPipelineValidatorCards(template.validators || []); + if (pipelineManifestCode) { + pipelineManifestCode.textContent = JSON.stringify(manifest, null, 2); + } + if (pipelineManifestEditor) pipelineManifestEditor.value = JSON.stringify(manifest, null, 2); + syncPipelineWorkerCards(template.workers || []); + showPipelineWorkerInspector(); + pipelineTemplateCards.forEach((card) => { + const isActive = card.dataset.templateCard === template.id; + card.classList.toggle("active", isActive); + card.setAttribute("aria-pressed", String(isActive)); + }); +} + +async function loadPipelineStudioState() { + return loadPipelineStudioStateForRun(currentPipelineExecutionRunId); +} + +async function loadPipelineStudioStateForRun(runId = "") { + if (!pipelineProjectSelect || !pipelineTemplateSelect) { + applyPipelineStudioContext(); + return; + } + const params = new URLSearchParams(location.search); + const routeProject = params.get("project") || ""; + const routeTemplate = params.get("template") || ""; + const routeRunId = params.get("run_id") || ""; + const project = routeProject || pipelineProjectSelect.value || "hard-proreq-project"; + const template = routeTemplate || pipelineTemplateSelect.value || "hard-proreq-task"; + if (routeProject && Array.from(pipelineProjectSelect.options).some((option) => option.value === routeProject)) { + pipelineProjectSelect.value = routeProject; + } + if (routeTemplate && Array.from(pipelineTemplateSelect.options).some((option) => option.value === routeTemplate)) { + pipelineTemplateSelect.value = routeTemplate; + } + try { + const selectedRunId = runId || routeRunId; + const runQuery = selectedRunId ? `&run_id=${encodeURIComponent(selectedRunId)}` : ""; + const payload = await apiGetJson(`${API_BASE}/dev-pipeline-studio?project=${encodeURIComponent(project)}&template=${encodeURIComponent(template)}${runQuery}`); + normalizePipelineState(payload); + applyPipelineStudioContext(); + return payload; + } catch (error) { + console.warn("Dev Pipeline Studio backend unavailable; using local fallback.", error); + pipelineStudioState = null; + applyPipelineStudioContext(); + return null; + } +} + +// ── Manifest Explorer ───────────────────────────────────────────────────────── + +let manifestExplorerPayload = null; +let currentManifestId = ""; +let currentManifestView = "json"; +let currentManifestReferenceKind = "all"; + +function manifestSlug(value, fallback = "manifest") { + const slug = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + return slug || fallback; +} + +function manifestPathFile(path, fallback = "manifest.json") { + const parts = String(path || "").split("/").filter(Boolean); + return parts.length ? parts[parts.length - 1] : fallback; +} + +function manifestPathDir(path, fallback = "workspace/runs/dev-pipeline-studio/docs-pages/latest/") { + const clean = String(path || "").trim(); + if (!clean) return fallback; + const index = clean.lastIndexOf("/"); + return index >= 0 ? `${clean.slice(0, index + 1)}` : fallback; +} + +function manifestStatusClass(status) { + const normalized = String(status || "active").toLowerCase().replace(/[^a-z0-9]+/g, "-"); + if (normalized.includes("pass")) return "passed"; + if (normalized.includes("complete") || normalized.includes("accept") || normalized.includes("merge")) return "completed"; + if (normalized.includes("fail") || normalized.includes("block") || normalized.includes("reject")) return "failed"; + return "active"; +} + +function manifestDisplayUpdated(value) { + const raw = String(value || "current").trim(); + const date = new Date(raw); + if (!Number.isNaN(date.getTime())) return date.toISOString().slice(0, 16).replace("T", " "); + return raw.length > 22 ? `${raw.slice(0, 19)}...` : raw; +} + +function manifestTypeGlyph(type) { + const glyphs = { + pipeline: "▣", + input: "▤", + worker: "▧", + integration: "⌁", + validator: "▦", + evidence: "▥", + }; + return glyphs[type] || "▧"; +} + +function manifestSchemaName(type) { + const schemas = { + pipeline: "pipeline_manifest.schema.json", + input: "input_manifest.schema.json", + worker: "worker_manifest.schema.json", + integration: "integration_config.schema.json", + validator: "validator_manifest.schema.json", + evidence: "evidence_manifest.schema.json", + }; + return schemas[type] || "manifest.schema.json"; +} + +function manifestReference(kind, name, path, description, usedIn = "") { + return { + kind, + name: String(name || path || "reference"), + path: String(path || ""), + description: String(description || ""), + usedIn: String(usedIn || ""), + }; +} + +function manifestEntry(options) { + const file = options.file || manifestPathFile(options.path, `${options.id}.json`); + const status = options.status || "Active"; + return { + id: options.id, + type: options.type || "worker", + name: options.name || options.id, + file, + path: options.path || file, + version: options.version || "v1", + active: options.active !== false, + status, + updated: options.updated || "Current run", + schema: options.schema || manifestSchemaName(options.type), + source: options.source || manifestPathDir(options.path), + tags: options.tags || [options.type || "manifest"], + lineage: options.lineage || [], + downstream: options.downstream || [], + validation: options.validation || [ + { label: "Schema valid", detail: options.schema || manifestSchemaName(options.type), passed: true }, + { label: "References resolved", detail: "Inputs and artifacts found in active pipeline state", passed: true }, + ], + references: options.references || [], + payload: options.payload || {}, + }; +} + +function selectedPipelineManifestTemplate() { + const payloadTemplate = selectedPipelinePayloadTemplate(); + if (payloadTemplate) return payloadTemplate; + const fallback = selectedPipelineStudioTemplate(); + return { + id: fallback.id, + label: fallback.label, + detail: fallback.detail, + validation_tier: fallback.validationTier, + risk: fallback.risk, + execution_model: fallback.executionModel || "ordered", + required_inputs: fallback.requiredInputs || [], + workers: fallback.workers || [], + validators: [ + { id: "smoke-plus", title: "Smoke-plus Validator", mode: "commands", status: "Passed" }, + { id: "contract", title: "Contract Validator", mode: "schema", status: "Passed" }, + { id: "evidence", title: "Evidence Validator", mode: "evidence", status: "Passed" }, + ], + }; +} + +function selectedPipelineManifestProject() { + const payloadProject = selectedPipelinePayloadProject(); + if (payloadProject) return payloadProject; + const fallback = selectedPipelineStudioProject(); + return { + id: fallback.key, + label: fallback.name, + surface: fallback.surface, + surface_value: fallback.surfaceValue, + owned_root: fallback.ownedRoot, + read_paths: fallback.readPaths || [], + }; +} + +function buildPipelineManifestExplorerData() { + const project = selectedPipelineManifestProject(); + const template = selectedPipelineManifestTemplate(); + const root = pipelineStudioState?.root || "workspace/runs/dev-pipeline-studio/docs-pages/latest"; + const generatedAt = pipelineStudioState?.generated_at || new Date().toISOString(); + const pipeline = pipelineStudioState?.pipeline || {}; + const templateId = template.id || pipelineStudioState?.selected?.template_id || "hard-proreq-task"; + const inputCards = pipeline.input_cards || template.required_inputs || []; + const workers = pipeline.workers || template.workers || []; + const integrations = pipeline.integration || []; + const validators = pipeline.validators || template.validators || []; + const evidence = pipeline.evidence || []; + const groupEntries = { + pipeline: [], + input: [], + worker: [], + integration: [], + validator: [], + evidence: [], + }; + const entries = []; + const add = (group, entry) => { + groupEntries[group].push(entry); + entries.push(entry); + return entry; + }; + const downstreamWorkers = workers.map((worker) => ({ + label: worker.title || worker.id || "Worker", + file: worker.file || `${worker.id}.json`, + type: "worker", + })); + const pipelinePath = `${root}/pipeline_manifest.json`; + add("pipeline", manifestEntry({ + id: "pipeline_manifest", + type: "pipeline", + name: pipeline.template ? `${pipeline.template} Pipeline` : "Pipeline Manifest", + file: "pipeline_manifest.json", + path: pipelinePath, + version: "v3", + status: pipeline.status || "Healthy", + updated: generatedAt, + schema: "pipeline_manifest.schema.json", + source: root, + tags: ["pipeline", templateId, template.execution_model || pipeline.execution_model || "ordered"], + downstream: downstreamWorkers, + references: [ + ...inputCards.map((item, index) => manifestReference("input", item.title || `Input ${index + 1}`, item.manifest || item.file || "", item.detail || "Required operator input", "Pipeline Manifest")), + ...workers.map((worker) => manifestReference("worker", worker.title || worker.id, worker.file || `${worker.id}.json`, worker.detail || worker.description || "Worker contract", "Pipeline Manifest")), + ...validators.map((validator) => manifestReference("command", validator.title || validator.id, validator.receipt || validator.file || "", validator.summary || "Validation command set", "Validation lane")), + ...evidence.map((item) => manifestReference("artifact", item.title || item.id, item.path || item.file || "", item.review_notes || "Evidence artifact", "Evidence bundle")), + ], + payload: { + schema_version: "cento.pipeline_manifest.explorer.v1", + id: pipeline.id || `${templateId}-pipeline`, + run_name: pipeline.run_name || `${templateId}-${project.id}`, + status: pipeline.status || "Healthy", + status_detail: pipeline.status_detail || "", + project: { + id: project.id, + label: project.label, + surface: project.surface, + read_paths: project.read_paths || [], + }, + template: { + id: templateId, + label: template.label, + detail: template.detail, + execution_model: template.execution_model || pipeline.execution_model || "ordered", + validation_tier: template.validation_tier || pipeline.validation?.tier || "", + risk: template.risk || "", + }, + manifests: { + inputs: inputCards.map((item, index) => pipelineInputId(item, index)), + workers: workers.map((worker) => worker.id || worker.title), + integration: integrations.map((item, index) => pipelineIntegrationId(item, index)), + validators: validators.map((item, index) => pipelineValidatorId(item, index)), + evidence: evidence.map((item, index) => pipelineEvidenceId(item, index)), + }, + budget: { + spent: pipeline.budget || "", + detail: pipeline.budget_detail || "", + }, + }, + })); + + inputCards.forEach((input, index) => { + const inputId = pipelineInputId(input, index); + const path = input.manifest ? `${root}/${input.manifest}` : `${root}/inputs/${templateId}_${inputId}.json`; + add("input", manifestEntry({ + id: `input_${manifestSlug(inputId)}`, + type: "input", + name: input.title || `Input ${index + 1}`, + file: manifestPathFile(path), + path, + status: input.status || "Configured", + updated: generatedAt, + tags: ["input", pipelineInputType(input), input.required === false ? "optional" : "required"], + lineage: [ + { label: "Pipeline Manifest", file: "pipeline_manifest.json", type: "pipeline" }, + { label: input.title || inputId, file: manifestPathFile(path), type: "input", current: true }, + ], + downstream: workers.slice(0, 3).map((worker) => ({ label: worker.title || worker.id, file: worker.file || `${worker.id}.json`, type: "worker" })), + references: [ + ...(input.paths || []).map((pathValue) => manifestReference("input", pathValue, pathValue, input.path_policy || "Read path", input.title)), + ...(input.artifacts || []).map((artifact) => manifestReference("artifact", manifestPathFile(artifact), artifact, input.evidence_policy || "Input artifact", input.title)), + ...(input.questions || []).map((question) => manifestReference("input", question.prompt || question.id, question.id || "", question.required === false ? "Optional question" : "Required question", input.title)), + ], + payload: { + schema_version: "cento.input_manifest.v1", + id: inputId, + project: project.id, + template_id: templateId, + title: input.title || "", + kind: pipelineInputType(input), + status: String(input.status || "configured").toLowerCase(), + required: input.required !== false, + detail: input.detail || input.file || "", + format: input.format || "", + image_refs: input.image_refs || [], + questions: input.questions || [], + paths: input.paths || [], + artifacts: input.artifacts || [], + evidence_policy: input.evidence_policy || "", + }, + })); + }); + + workers.forEach((worker, index) => { + const workerId = String(worker.id || `worker-${index + 1}`); + const templateWorker = (template.workers || []).find((item) => String(item.id || "") === workerId) || {}; + const selectedManifest = pipeline.inspector?.manifest?.task_id === workerId ? pipeline.inspector.manifest : null; + const manifestPayload = selectedManifest || { + schema_version: "cento.worker_manifest.v1", + id: `${workerId}_worker_01`, + project: project.id, + template_id: templateId, + type: template.worker_type || "pipeline_worker", + task_id: workerId, + description: worker.detail || worker.description || templateWorker.description || "", + owned_paths: [`${project.owned_root || "workspace/generated"}/${worker.file || `${workerId}.json`}`], + read_paths: [...(project.read_paths || []), `templates/pipelines/${templateId}.json`], + dependencies: templateWorker.dependencies || worker.dependencies || [], + acceptance: [ + "Template output is valid", + "Only declared owned paths change", + "Validation evidence is attached before review", + ], + validation: { tier: template.validation_tier || pipeline.validation?.tier || "" }, + }; + const path = `${root}/workers/${templateId}_${workerId}.json`; + const dependencies = manifestPayload.dependencies || []; + add("worker", manifestEntry({ + id: `worker_${manifestSlug(workerId)}`, + type: "worker", + name: worker.title || templateWorker.title || workerId, + file: manifestPathFile(path), + path, + status: worker.status || "Completed", + updated: generatedAt, + tags: ["worker", worker.stage || workerStageKey(worker, index), template.validation_tier || "validation"], + lineage: [ + { label: "Pipeline Manifest", file: "pipeline_manifest.json", type: "pipeline" }, + ...dependencies.map((dependency) => ({ label: dependency, file: `${dependency}.json`, type: "worker" })), + { label: worker.title || workerId, file: manifestPathFile(path), type: "worker", current: true }, + ], + downstream: integrations.filter((item) => (item.dependencies || []).includes(workerId) || item.id === workerId).map((item) => ({ label: item.title || item.id, file: item.receipt || item.file || "integration_receipt.json", type: "integration" })), + references: [ + ...(manifestPayload.read_paths || []).map((pathValue) => manifestReference("input", manifestPathFile(pathValue, pathValue), pathValue, "Read path", worker.title || workerId)), + ...(manifestPayload.owned_paths || []).map((pathValue) => manifestReference("artifact", manifestPathFile(pathValue), pathValue, "Owned output", worker.title || workerId)), + ...dependencies.map((dependency) => manifestReference("worker", dependency, `${dependency}.json`, "Worker dependency", worker.title || workerId)), + ], + payload: manifestPayload, + })); + }); + + integrations.forEach((integration, index) => { + const integrationId = pipelineIntegrationId(integration, index); + const path = integration.config || integration.receipt || `integration/configs/${integrationId}.json`; + add("integration", manifestEntry({ + id: `integration_${manifestSlug(integrationId)}`, + type: "integration", + name: integration.title || integrationId, + file: manifestPathFile(path), + path: path.startsWith("workspace/") ? path : `${root}/${path}`, + status: integration.status || "Accepted", + updated: generatedAt, + tags: ["integration", integration.mode || "dependency-order", integration.status || "accepted"], + lineage: [ + ...((integration.dependencies || []).map((dependency) => ({ label: dependency, file: `${dependency}.json`, type: "worker" }))), + { label: integration.title || integrationId, file: manifestPathFile(path), type: "integration", current: true }, + ], + downstream: validators.slice(0, 3).map((validator) => ({ label: validator.title || validator.id, file: validator.receipt || validator.file || "validator.json", type: "validator" })), + references: [ + ...(integration.dependencies || []).map((dependency) => manifestReference("worker", dependency, `${dependency}.json`, "Dependency receipt", integration.title)), + ...(integration.artifacts || []).map((artifact) => manifestReference("artifact", manifestPathFile(artifact), artifact, "Integrated artifact", integration.title)), + ...(integration.gates || []).map((gate) => manifestReference("command", gate, "", "Receipt gate", integration.title)), + manifestReference("artifact", manifestPathFile(integration.receipt || "integration_receipt.json"), integration.receipt || "", "Integration receipt", integration.title), + ], + payload: { + schema_version: "cento.integration_manifest.v1", + ...integration, + id: integrationId, + project: project.id, + template_id: templateId, + }, + })); + }); + + validators.forEach((validator, index) => { + const validatorId = pipelineValidatorId(validator, index); + const path = validator.config || validator.receipt || `validation/validator_configs/${validatorId}.json`; + add("validator", manifestEntry({ + id: `validator_${manifestSlug(validatorId)}`, + type: "validator", + name: validator.title || validatorId, + file: manifestPathFile(path), + path: path.startsWith("workspace/") ? path : `${root}/${path}`, + status: validator.status || "Passed", + updated: validator.executed_at || generatedAt, + tags: ["validator", validator.tier || template.validation_tier || "smoke", validator.mode || "commands"], + lineage: [ + ...(integrations.slice(-2).map((item) => ({ label: item.title || item.id, file: item.receipt || item.file || "integration_receipt.json", type: "integration" }))), + { label: validator.title || validatorId, file: manifestPathFile(path), type: "validator", current: true }, + ], + downstream: evidence.slice(0, 3).map((item) => ({ label: item.title || item.id, file: item.file || item.path || "evidence.json", type: "evidence" })), + references: [ + ...(validator.commands || []).map((command) => manifestReference("command", command, command, "Validation command", validator.title)), + ...(validator.evidence || []).map((artifact) => manifestReference("artifact", manifestPathFile(artifact), artifact, "Required evidence", validator.title)), + ...(validator.gates || []).map((gate) => manifestReference("command", gate, "", "Validation gate", validator.title)), + ...(validator.schema_paths || []).map((schema) => manifestReference("artifact", manifestPathFile(schema), schema, "Schema path", validator.title)), + ], + validation: [ + { label: "Schema valid", detail: manifestSchemaName("validator"), passed: true }, + { label: "References resolved", detail: `${(validator.commands || []).length} commands, ${(validator.evidence || []).length} evidence paths`, passed: true }, + { label: "Blocking policy", detail: validator.blocking === false ? "Non-blocking validator" : "Blocks handoff on failure", passed: true }, + ], + payload: { + schema_version: "cento.validator_manifest.v1", + ...validator, + id: validatorId, + project: project.id, + template_id: templateId, + }, + })); + }); + + evidence.forEach((item, index) => { + const evidenceId = pipelineEvidenceId(item, index); + const path = item.config || item.path || `evidence/configs/${evidenceId}.json`; + add("evidence", manifestEntry({ + id: `evidence_${manifestSlug(evidenceId)}`, + type: "evidence", + name: item.title || evidenceId, + file: manifestPathFile(path), + path: path.startsWith("workspace/") ? path : `${root}/${path}`, + status: item.status || "Configured", + updated: generatedAt, + tags: ["evidence", item.kind || "artifact", item.state || "configured"], + lineage: [ + ...(validators.slice(-2).map((validator) => ({ label: validator.title || validator.id, file: validator.receipt || validator.file || "validator.json", type: "validator" }))), + { label: item.title || evidenceId, file: manifestPathFile(path), type: "evidence", current: true }, + ], + references: [ + ...(item.required_sources || []).map((source) => manifestReference("artifact", manifestPathFile(source), source, "Required source", item.title)), + manifestReference("artifact", manifestPathFile(item.path || item.file || "evidence.json"), item.path || item.file || "", item.publish_policy || "Published evidence", item.title), + ], + payload: { + schema_version: "cento.evidence_manifest.v1", + ...item, + id: evidenceId, + project: project.id, + template_id: templateId, + }, + })); + }); + + return { + groups: [ + { id: "pipeline", label: "Pipeline Manifest", entries: groupEntries.pipeline }, + { id: "input", label: "Input Manifests", entries: groupEntries.input }, + { id: "worker", label: "Worker Manifests", entries: groupEntries.worker }, + { id: "integration", label: "Integration Manifests", entries: groupEntries.integration }, + { id: "validator", label: "Validator Manifests", entries: groupEntries.validator }, + { id: "evidence", label: "Evidence Manifests", entries: groupEntries.evidence }, + ].filter((group) => group.entries.length), + entries, + defaultId: entries.find((entry) => entry.type === "validator")?.id || entries[0]?.id || "", + }; +} + +function findManifestEntry(manifestId) { + return (manifestExplorerPayload?.entries || []).find((entry) => entry.id === manifestId) || null; +} + +function highlightManifestJson(json) { + return json + .replace(/&/g, "&").replace(//g, ">") + .replace(/("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, (m) => { + if (/^"/.test(m)) return /:$/.test(m) ? `${m}` : `${m}`; + if (/true|false/.test(m)) return `${m}`; + if (m === "null") return `${m}`; + return `${m}`; + }); +} + +function renderManifestCode(jsonText) { + if (!manifestCodeEl || !manifestLineNumsEl) return; + const lines = jsonText.split("\n"); + manifestLineNumsEl.innerHTML = lines.map((_, i) => `${i + 1}`).join(""); + manifestCodeEl.innerHTML = highlightManifestJson(jsonText); +} + +function renderManifestList() { + if (!manifestListScroll || !manifestExplorerPayload) return; + manifestListScroll.innerHTML = manifestExplorerPayload.groups.map((group) => ` +
    +
    ${escapeHtml(group.label)}${group.entries.length}
    +
      + ${group.entries.map((entry) => ` +
    • + +
      + ${escapeHtml(entry.file)} + ${escapeHtml(entry.version)}${entry.active ? " (active)" : ""} · ${escapeHtml(manifestDisplayUpdated(entry.updated))} +
      + +
    • + `).join("")} +
    +
    + `).join(""); +} + +function manifestReferenceMatchesKind(reference, kind) { + if (kind === "all") return true; + if (kind === "artifact") return reference.kind === "artifact" || reference.kind === "schema"; + return reference.kind === kind; +} + +function renderManifestReferences(entry) { + const references = Array.isArray(entry.references) ? entry.references : []; + const visible = references.filter((reference) => manifestReferenceMatchesKind(reference, currentManifestReferenceKind)); + if (manifestReferenceCount) manifestReferenceCount.textContent = String(references.length); + if (manifestReferenceSummary) { + manifestReferenceSummary.textContent = references.length + ? `${entry.name} resolves ${references.length} direct reference${references.length === 1 ? "" : "s"} from the active pipeline.` + : `${entry.name} has no direct references in the active pipeline.`; + } + if (manifestReferenceTabs) { + const counts = { + all: references.length, + input: references.filter((reference) => reference.kind === "input").length, + command: references.filter((reference) => reference.kind === "command").length, + artifact: references.filter((reference) => reference.kind === "artifact" || reference.kind === "schema").length, + worker: references.filter((reference) => reference.kind === "worker").length, + }; + const labels = { all: "All", input: "Inputs", command: "Commands", artifact: "Artifacts", worker: "Workers" }; + manifestReferenceTabs.querySelectorAll("a[data-reference-kind]").forEach((link) => { + const kind = link.dataset.referenceKind || "all"; + link.classList.toggle("active", kind === currentManifestReferenceKind); + link.textContent = `${labels[kind] || kind} (${counts[kind] || 0})`; + }); + } + if (!manifestReferenceRows) return; + if (!visible.length) { + manifestReferenceRows.innerHTML = `

    No ${escapeHtml(currentManifestReferenceKind === "all" ? "" : currentManifestReferenceKind)} references for this manifest.

    `; + return; + } + manifestReferenceRows.innerHTML = ` +
    + TypeName / PathDescriptionUsed In +
    + ${visible.map((reference) => ` +
    + ${escapeHtml(reference.kind)} + ${escapeHtml(reference.path || reference.name)} + ${escapeHtml(reference.description || "-")} + ${escapeHtml(reference.usedIn || entry.name)} +
    + `).join("")} + `; +} + +function manifestCodePayload(entry) { + if (currentManifestView === "schema") { + return { + schema: entry.schema, + type: entry.type, + status: "valid", + required_fields: Object.keys(entry.payload || {}).slice(0, 8), + source: entry.source, + }; + } + if (currentManifestView === "references") { + return { + manifest: entry.id, + references: entry.references, + lineage: entry.lineage, + downstream: entry.downstream, + }; + } + if (currentManifestView === "diff") { + return { + manifest: entry.id, + from: entry.previous_version || "previous", + to: entry.version, + status: "preview", + changed_fields: ["status", "updated", "references"], + note: "Diff preview is synthesized from the active pipeline state.", + }; + } + if (currentManifestView === "raw") { + return { + path: entry.path, + payload: entry.payload, + }; + } + return entry.payload; +} + +function renderManifestCodeView(entry) { + renderManifestCode(JSON.stringify(manifestCodePayload(entry), null, 2)); + manifestExplorerEl?.querySelectorAll(".manifestViewerTabs a[data-manifest-view]").forEach((link) => { + link.classList.toggle("active", link.dataset.manifestView === currentManifestView); + }); +} + +function selectManifest(manifestId) { + const meta = findManifestEntry(manifestId) || findManifestEntry(manifestExplorerPayload?.defaultId || ""); + if (!meta) return; + currentManifestId = meta.id; + manifestExplorerEl?.querySelectorAll(".manifestItem").forEach((el) => { + el.classList.toggle("selected", el.dataset.manifestId === meta.id); + }); + renderManifestCodeView(meta); + const set = (id, val) => { const el = document.querySelector(id); if (el) el.innerHTML = val; }; + const nameEl = document.querySelector("#manifestViewerName"); + const fileEl = document.querySelector("#manifestViewerFile"); + const badgeEl = document.querySelector("#manifestViewerBadge"); + const iconEl = manifestExplorerEl?.querySelector(".manifestViewer .manifestViewerTitle .manifestTypeIcon"); + if (nameEl) nameEl.textContent = meta.name; + if (fileEl) fileEl.textContent = meta.file; + if (badgeEl) { badgeEl.textContent = meta.version + (meta.active ? " (active)" : ""); badgeEl.className = "miBadge" + (meta.active ? " active" : ""); } + if (iconEl) { iconEl.className = `manifestTypeIcon ${meta.type} large`; iconEl.textContent = manifestTypeGlyph(meta.type); } + set("#mdType", escapeHtml(meta.type)); + set("#mdName", escapeHtml(meta.name)); + set("#mdId", escapeHtml(meta.id)); + set("#mdVersion", `${escapeHtml(meta.version)}${meta.active ? " (active)" : ""}`); + set("#mdStatus", `${escapeHtml(meta.status)}`); + set("#mdUpdated", escapeHtml(meta.updated)); + set("#mdSchema", `${escapeHtml(meta.schema)}`); + set("#mdSource", `${escapeHtml(meta.source)}`); + const lineageEl = document.querySelector("#manifestLineage"); + if (lineageEl) lineageEl.innerHTML = meta.lineage.length ? meta.lineage.map((n, i) => (i > 0 ? `` : "") + `
    ${escapeHtml(n.label)}${escapeHtml(n.file)}
    `).join("") : '

    — No upstream manifests

    '; + const dsEl = document.querySelector("#manifestDownstream"); + if (dsEl) dsEl.innerHTML = meta.downstream.length ? meta.downstream.map((n) => `
    ${escapeHtml(n.label)}${escapeHtml(n.file)}
    `).join("") : "— No downstream manifests"; + const valEl = document.querySelector("#manifestValidationList"); + if (valEl) valEl.innerHTML = meta.validation.map((v) => `
    ${escapeHtml(v.label)}${escapeHtml(v.detail)}
    `).join(""); + const tagsEl = document.querySelector("#manifestTags"); + if (tagsEl) tagsEl.innerHTML = meta.tags.map((t) => `${escapeHtml(t)}`).join(""); + renderManifestReferences(meta); +} + +function refreshManifestExplorer(options = {}) { + if (!manifestExplorerEl) return; + manifestExplorerPayload = buildPipelineManifestExplorerData(); + const hasCurrent = options.preserveSelection && currentManifestId && findManifestEntry(currentManifestId); + if (!hasCurrent) currentManifestId = manifestExplorerPayload.defaultId; + renderManifestList(); + selectManifest(currentManifestId); + const q = (manifestSearchInput?.value || "").toLowerCase(); + if (q) { + manifestExplorerEl.querySelectorAll(".manifestItem").forEach((el) => { + const text = `${el.querySelector("strong")?.textContent || ""} ${el.dataset.manifestType || ""}`.toLowerCase(); + el.classList.toggle("manifestItemHidden", !text.includes(q)); + }); + } +} + +function initManifestExplorer() { + refreshManifestExplorer(); + manifestListScroll?.addEventListener("click", (e) => { + const item = e.target.closest(".manifestItem[data-manifest-id]"); + if (!item || e.target.closest(".manifestItemMenu")) return; + selectManifest(item.dataset.manifestId); + }); + manifestListScroll?.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + const item = e.target.closest(".manifestItem[data-manifest-id]"); + if (item) selectManifest(item.dataset.manifestId); + }); + manifestSearchInput?.addEventListener("input", () => { + const q = (manifestSearchInput.value || "").toLowerCase(); + manifestExplorerEl?.querySelectorAll(".manifestItem").forEach((el) => { + const text = `${el.querySelector("strong")?.textContent || ""} ${el.dataset.manifestType || ""}`.toLowerCase(); + el.classList.toggle("manifestItemHidden", Boolean(q && !text.includes(q))); + }); + }); + manifestExplorerEl?.querySelector(".manifestViewerTabs")?.addEventListener("click", (e) => { + const link = e.target.closest("a[data-manifest-view]"); + if (!link) return; + e.preventDefault(); + currentManifestView = link.dataset.manifestView || "json"; + const item = findManifestEntry(currentManifestId); + if (item) renderManifestCodeView(item); + }); + manifestReferenceTabs?.addEventListener("click", (e) => { + const link = e.target.closest("a[data-reference-kind]"); + if (!link) return; + e.preventDefault(); + currentManifestReferenceKind = link.dataset.referenceKind || "all"; + const item = findManifestEntry(currentManifestId); + if (item) renderManifestReferences(item); + }); + manifestReferenceMode?.addEventListener("change", () => { + const item = findManifestEntry(currentManifestId); + if (item) renderManifestReferences(item); + }); + document.querySelector("#manifestFormatBtn")?.addEventListener("click", () => { + const item = findManifestEntry(currentManifestId); + if (item) renderManifestCodeView(item); + }); + document.querySelector("#manifestValidateBtn")?.addEventListener("click", () => { + document.querySelectorAll("#manifestValidationList .mvCheck").forEach((c) => c.classList.add("passed")); + }); +} + +const PIPELINE_TAB_HASHES = { + overview: "pipeline-overview", + contracts: "dev-pipeline-studio", + "execution-flow": "pipeline-flow", + "manifest-explorer": "manifest-explorer", + evidence: "pipeline-evidence", + "best-practices": "pipeline-practices", +}; + +function pipelineTabFromHash(hash = location.hash) { + const clean = String(hash || "").replace(/^#/, ""); + const match = Object.entries(PIPELINE_TAB_HASHES).find(([, value]) => value === clean); + return match ? match[0] : "contracts"; +} + +function setPipelineTab(tab, options = {}) { + currentPipelineTab = tab; + document.querySelectorAll(".pipelineTabs a[data-pipeline-tab]").forEach((a) => { + a.classList.toggle("active", a.dataset.pipelineTab === tab); + }); + const isExplorer = tab === "manifest-explorer"; + const isExecution = tab === "execution-flow"; + const studioRoot = document.querySelector("#dev-pipeline-studio"); + studioRoot?.classList.toggle("pipelineFlowMode", isExecution); + studioRoot?.classList.toggle("pipelineExplorerMode", isExplorer); + document.querySelectorAll('.sdHubRailLinks a[href="/dev-pipeline-studio#manifest-explorer"]').forEach((link) => { + link.classList.toggle("active", isExplorer); + if (isExplorer) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); + document.querySelector("#dev-pipeline-studio > .pipelineHero")?.classList.toggle("hidden", isExplorer); + document.querySelector("#dev-pipeline-studio > .pipelineContextPanel")?.classList.toggle("hidden", isExplorer); + document.querySelector("#dev-pipeline-studio .pipelineWorkbench")?.classList.toggle("hidden", isExplorer || isExecution); + pipelineExecutionPage?.classList.toggle("hidden", !isExecution); + manifestExplorerEl?.classList.toggle("hidden", !isExplorer); + if (isExecution) { + const runsDisclosure = document.querySelector(".pipelineExecutionRuns"); + const logsDisclosure = document.querySelector(".pipelineExecutionLogs"); + if (runsDisclosure) runsDisclosure.open = false; + if (logsDisclosure) logsDisclosure.open = false; + } + if (options.updateHash) { + const hash = PIPELINE_TAB_HASHES[tab] || PIPELINE_TAB_HASHES.contracts; + history.replaceState(null, "", `/dev-pipeline-studio#${hash}`); + } + if (isExplorer && manifestExplorerEl && !manifestExplorerEl.dataset.initialized) { + manifestExplorerEl.dataset.initialized = "1"; + initManifestExplorer(); + } else if (isExplorer) { + refreshManifestExplorer({ preserveSelection: true }); + } else if (isExecution) { + clearPipelineExecutionAnimation(); + renderPipelineExecutionFlow(); + } +} + +function initPipelineStudioControls() { + if (pipelineStudioControlsInitialized || !pipelineProjectSelect || !pipelineTemplateSelect) return; + pipelineProjectSelect.addEventListener("change", () => { + pipelineSelectedInputId = ""; + pipelineSelectedIntegrationId = ""; + pipelineSelectedValidationId = ""; + void loadPipelineStudioState(); + }); + pipelineTemplateSelect.addEventListener("change", () => { + pipelineSelectedInputId = ""; + pipelineSelectedIntegrationId = ""; + pipelineSelectedValidationId = ""; + void loadPipelineStudioState(); + }); + if (pipelineTemplateLibrary) { + pipelineTemplateLibrary.addEventListener("click", (event) => { + const card = event.target.closest("[data-template-card]"); + if (!card || !pipelineTemplateSelect) return; + pipelineTemplateSelect.value = card.dataset.templateCard || "hard-proreq-task"; + pipelineSelectedInputId = ""; + pipelineSelectedIntegrationId = ""; + pipelineSelectedValidationId = ""; + void loadPipelineStudioState(); + }); + } + if (pipelineSaveDraftButton) { + pipelineSaveDraftButton.addEventListener("click", () => { + void savePipelineDraft("save"); + }); + } + if (pipelineDuplicateButton) { + pipelineDuplicateButton.addEventListener("click", () => { + void savePipelineDraft("duplicate"); + }); + } + if (pipelineNewTemplateButton) { + pipelineNewTemplateButton.addEventListener("click", () => { + void savePipelineDraft("new"); + }); + } + const pipelineTabsNav = document.querySelector(".pipelineTabs"); + if (pipelineTabsNav) { + pipelineTabsNav.addEventListener("click", (event) => { + const link = event.target.closest("a[data-pipeline-tab]"); + if (!link) return; + event.preventDefault(); + setPipelineTab(link.dataset.pipelineTab || "contracts", { updateHash: true }); + }); + } + if (pipelineExecutionPage) { + pipelineExecutionPage.addEventListener("click", (event) => { + const stageButton = event.target.closest("[data-execution-stage]"); + if (stageButton) { + selectPipelineExecutionStage(stageButton.dataset.executionStage || ""); + return; + } + const runButton = event.target.closest("[data-execution-run-id]"); + if (runButton) { + void loadPipelineExecutionRun(runButton.dataset.executionRunId || ""); + return; + } + const logButton = event.target.closest("[data-execution-log-filter]"); + if (logButton) setPipelineExecutionLogFilter(logButton.dataset.executionLogFilter || "all"); + }); + } + if (pipelineExecutionRunButton) { + pipelineExecutionRunButton.addEventListener("click", () => { + void runPipelineExecutionDelivery(); + }); + } + if (pipelineExecutionLogSearch) { + pipelineExecutionLogSearch.addEventListener("input", renderPipelineExecutionLogs); + } + if (pipelineInspectorNav) { + pipelineInspectorNav.addEventListener("click", (event) => { + const link = event.target.closest("a[data-inspector-tab]"); + if (!link) return; + event.preventDefault(); + setInspectorTab(link.dataset.inspectorTab || "manifest"); + }); + } + const logsCopyButton = document.querySelector("#logsCopyButton"); + if (logsCopyButton) { + logsCopyButton.addEventListener("click", () => { + const scroll = document.querySelector("#logsScroll"); + if (!scroll) return; + const text = Array.from(scroll.querySelectorAll(".logEntry")).map((el) => { + const time = el.querySelector("time")?.textContent || ""; + const msg = el.querySelector("span")?.textContent || ""; + return `[${time}] ${msg}`; + }).join("\n"); + navigator.clipboard?.writeText(text).catch(() => {}); + }); + } + if (pipelineFormatManifestButton) { + pipelineFormatManifestButton.addEventListener("click", () => { + try { + const manifest = parsePipelineManifestEditor(); + if (pipelineManifestEditor) pipelineManifestEditor.value = JSON.stringify(manifest || {}, null, 2); + setPipelineManifestStatus("Worker manifest formatted"); + } catch (error) { + setPipelineManifestStatus(`Format failed: ${error.message}`, true); + } + }); + } + if (pipelineSaveManifestButton) { + pipelineSaveManifestButton.addEventListener("click", () => { + void savePipelineSelectedManifest(); + }); + } + if (pipelineInputSaveButton) { + pipelineInputSaveButton.addEventListener("click", () => { + void savePipelineSelectedInput(); + }); + } + if (pipelineInputTypeSelect) { + pipelineInputTypeSelect.addEventListener("change", () => { + updatePipelineInputTypeEditors(pipelineInputTypeSelect.value || "text"); + }); + } + if (pipelineValidationSaveButton) { + pipelineValidationSaveButton.addEventListener("click", () => { + void savePipelineSelectedValidation(); + }); + } + if (pipelineValidationRunButton) { + pipelineValidationRunButton.addEventListener("click", () => { + void runPipelineSelectedValidation(); + }); + } + if (pipelineIntegrationSaveButton) { + pipelineIntegrationSaveButton.addEventListener("click", () => { + void savePipelineSelectedIntegration(); + }); + } + if (pipelineEvidenceSaveButton) { + pipelineEvidenceSaveButton.addEventListener("click", () => { + void savePipelineSelectedEvidence(); + }); + } + document.querySelectorAll("[data-integration-view]").forEach((button) => { + button.addEventListener("click", () => { + updatePipelineIntegrationView(button.dataset.integrationView || "order"); + }); + }); + if (pipelineValidationModeSelect) { + pipelineValidationModeSelect.addEventListener("change", () => { + updatePipelineValidationModeButtons(pipelineValidationModeSelect.value || "commands"); + }); + } + document.querySelectorAll("[data-validation-mode]").forEach((button) => { + button.addEventListener("click", () => { + const mode = button.dataset.validationMode || "commands"; + if (pipelineValidationModeSelect) pipelineValidationModeSelect.value = mode; + updatePipelineValidationModeButtons(mode); + }); + }); + if (pipelineValidationAddCommandButton) { + pipelineValidationAddCommandButton.addEventListener("click", () => { + addPipelineValidationRow("commands", "python3 -m json.tool workspace/runs/dev-pipeline-studio/docs-pages/latest/pipeline_manifest.json"); + syncValidationRowsToTextareas(); + }); + } + if (pipelineValidationAddEvidenceButton) { + pipelineValidationAddEvidenceButton.addEventListener("click", () => { + addPipelineValidationRow("evidence", "validation/validator_manifest.json"); + syncValidationRowsToTextareas(); + }); + } + if (pipelineValidationAddGateButton) { + pipelineValidationAddGateButton.addEventListener("click", () => { + addPipelineValidationRow("gates", "Blocking validator prevents handoff until resolved"); + syncValidationRowsToTextareas(); + }); + } + if (pipelineValidationAddSchemaButton) { + pipelineValidationAddSchemaButton.addEventListener("click", () => { + addPipelineValidationRow("schema", "integration/integration_lane.json"); + syncValidationRowsToTextareas(); + }); + } + [ + [pipelineValidationCommandsInput, "commands"], + [pipelineValidationEvidenceInput, "evidence"], + [pipelineValidationGatesInput, "gates"], + [pipelineValidationSchemaInput, "schema"] + ].forEach(([textarea, kind]) => { + textarea?.addEventListener("input", () => syncValidationTextareaToRows(kind)); + }); + document.querySelector(".pipelineValidationTypedEditors")?.addEventListener("click", (event) => { + const removeButton = event.target.closest("[data-validation-row-remove]"); + if (!removeButton) return; + removeButton.closest(".pipelineValidationRow")?.remove(); + syncValidationRowsToTextareas(); + }); + document.querySelector(".pipelineValidationTypedEditors")?.addEventListener("input", () => { + syncValidationRowsToTextareas(); + }); + if (pipelineValidationUseIntegrationButton) { + pipelineValidationUseIntegrationButton.addEventListener("click", () => { + importPipelineIntegrationContext(pipelineStudioState?.pipeline?.integration || []); + }); + } + if (pipelineValidationIntegrationContext) { + pipelineValidationIntegrationContext.addEventListener("click", (event) => { + const button = event.target.closest("[data-import-integration-step]"); + if (!button) return; + const index = Number.parseInt(button.dataset.importIntegrationStep || "0", 10); + const step = (pipelineStudioState?.pipeline?.integration || [])[index]; + if (step) importPipelineIntegrationContext([step]); + }); + } + document.querySelector(".pipelineStageGrid")?.addEventListener("click", (event) => { + const addButton = event.target.closest("[data-pipeline-add-element]"); + if (addButton) { + event.preventDefault(); + event.stopPropagation(); + void addPipelineStageElement(addButton.dataset.pipelineAddElement || "", addButton.dataset.pipelineAddStage || ""); + return; + } + const actionButton = event.target.closest("[data-pipeline-card-action]"); + if (actionButton) { + event.preventDefault(); + event.stopPropagation(); + const type = actionButton.dataset.elementType || ""; + const id = actionButton.dataset.elementId || ""; + if (actionButton.dataset.pipelineCardAction === "delete") { + void deletePipelineStageElement(type, id); + } else { + openPipelineElementEditor(type, id); + } + return; + } + const inputCard = event.target.closest(".pipelineCard.operatorInput"); + const inputId = inputCard?.dataset?.inputId || ""; + if (inputId) { + showPipelineInputInspector(inputId); + return; + } + const integrationCard = event.target.closest(".pipelineCard.receipt"); + const integrationId = integrationCard?.dataset?.integrationId || ""; + if (integrationId) { + showPipelineIntegrationInspector(integrationId); + return; + } + const validatorCard = event.target.closest(".pipelineCard.validator"); + const validatorId = validatorCard?.dataset?.validatorId || ""; + if (validatorId) { + showPipelineValidationInspector(validatorId); + return; + } + const evidenceCard = event.target.closest(".pipelineCard.evidence"); + const evidenceId = evidenceCard?.dataset?.evidenceId || ""; + if (evidenceId) { + showPipelineEvidenceInspector(evidenceId); + return; + } + const card = event.target.closest(".pipelineCard.worker"); + const workerId = card?.dataset?.workerId || ""; + if (!workerId) return; + pipelineSelectedInputId = ""; + showPipelineWorkerInspector(); + void savePipelineDraft("select_worker", { workerId, includeManifest: false }); + }); + document.querySelector(".pipelineStageGrid")?.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + const inputCard = event.target.closest(".pipelineCard.operatorInput"); + const inputId = inputCard?.dataset?.inputId || ""; + if (inputId) { + event.preventDefault(); + showPipelineInputInspector(inputId); + return; + } + const integrationCard = event.target.closest(".pipelineCard.receipt"); + const integrationId = integrationCard?.dataset?.integrationId || ""; + if (integrationId) { + event.preventDefault(); + showPipelineIntegrationInspector(integrationId); + return; + } + const validatorCard = event.target.closest(".pipelineCard.validator"); + const validatorId = validatorCard?.dataset?.validatorId || ""; + if (validatorId) { + event.preventDefault(); + showPipelineValidationInspector(validatorId); + return; + } + const evidenceCard = event.target.closest(".pipelineCard.evidence"); + const evidenceId = evidenceCard?.dataset?.evidenceId || ""; + if (evidenceId) { + event.preventDefault(); + showPipelineEvidenceInspector(evidenceId); + return; + } + const card = event.target.closest(".pipelineCard.worker"); + const workerId = card?.dataset?.workerId || ""; + if (!workerId) return; + event.preventDefault(); + pipelineSelectedInputId = ""; + showPipelineWorkerInspector(); + void savePipelineDraft("select_worker", { workerId, includeManifest: false }); + }); + pipelineStudioControlsInitialized = true; + void loadPipelineStudioState(); +} + +function setOptionalHidden(element, isHidden) { + if (element) element.classList.toggle("hidden", isHidden); +} + +function hideSoftwareDeliveryViews() { + setOptionalHidden(homeView, true); + setOptionalHidden(softwareDeliveryHubView, true); + setOptionalHidden(devPipelineStudioView, true); + setOptionalHidden(patchSwarmView, true); +} + +function hideResearchViews() { + setOptionalHidden(researchView, true); + setOptionalHidden(codebaseIntelligenceView, true); +} + +function routeFromLocation() { + if (location.pathname === "/") return "home"; + if (location.pathname === "/software-delivery-hub") return "software-delivery"; + if (location.pathname === "/patch-swarm" || location.pathname.startsWith("/patch-swarm/runs/")) return "patch-swarm"; + if (location.pathname === "/dev-pipeline-studio") return "dev-pipeline-studio"; + if (location.pathname === "/codebase-intelligence") return "codebase-intelligence"; + if (location.pathname === "/review") return "review"; + if (location.pathname === "/cluster") return "cluster"; + if (location.pathname === "/consulting") return "consulting"; + if (location.pathname === "/factory") return "factory"; + if (location.pathname === "/research-center") return "research"; + if (location.pathname === "/docs") return "docs"; + if (location.pathname === "/issues" || location.pathname.startsWith("/issues/")) return "issues"; + return "home"; +} + +function hasMainRoute(route) { + return Array.from(mainNavLinks).some((link) => link.dataset.mainRoute === route); +} + +function setSdHubRailActive(route) { + const activeRoute = route === "dev-pipeline-studio" || route === "factory" || route === "software-delivery" || route === "patch-swarm" + ? route + : route === "issues" || route === "review" + ? "issues" + : ""; + sdHubRailLinks.forEach((link) => { + const isActive = link.dataset.sdHubRoute === activeRoute; + link.classList.toggle("active", isActive); + if (isActive) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); +} + +function setResearchRailActive(route) { + const activeRoute = route === "codebase-intelligence" ? "codebase-intelligence" : route === "research" ? "research" : ""; + researchRailLinks.forEach((link) => { + const isActive = link.dataset.researchRoute === activeRoute; + link.classList.toggle("active", isActive); + if (isActive) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); +} + +function setNavActive(route) { + const activeRoute = route || routeFromLocation(); + let activeMain = "taskstream"; + if (activeRoute === "home") { + activeMain = homeView ? "home" : "taskstream"; + } else if (activeRoute === "software-delivery" || activeRoute === "factory" || activeRoute === "issues" || activeRoute === "dev-pipeline-studio" || activeRoute === "patch-swarm") { + activeMain = softwareDeliveryHubView ? "software-delivery" : activeRoute === "factory" ? "factory" : "taskstream"; + } else if (activeRoute === "review") { + activeMain = hasMainRoute("review") ? "review" : "taskstream"; + } else if (activeRoute === "codebase-intelligence") { + activeMain = "research"; + } else if (["cluster", "consulting", "docs", "research"].includes(activeRoute)) { + activeMain = activeRoute; + } + mainNavLinks.forEach((link) => { + link.classList.toggle("active", link.dataset.mainRoute === activeMain); + }); + primaryNavLinks.forEach((link) => { + link.classList.toggle("active", link.dataset.navRoute === activeRoute); + }); + if (taskstreamNav) taskstreamNav.classList.toggle("hidden", activeMain !== "taskstream"); + document.body.classList.toggle("homeMode", activeMain === "home"); + document.body.classList.toggle("softwareDeliveryMode", activeMain === "software-delivery"); + document.body.classList.toggle("docsMode", activeMain === "docs"); + document.body.classList.toggle("researchMode", activeMain === "research"); + document.body.classList.toggle("codebaseMode", activeRoute === "codebase-intelligence"); + setSdHubRailActive(activeMain === "software-delivery" ? activeRoute : ""); + setResearchRailActive(activeMain === "research" ? activeRoute : ""); + if (activeMain !== "docs") { + document.body.classList.remove("docsAppPage"); + document.body.classList.remove("docsPipelinePage"); + document.body.classList.remove("docsParallelPage"); + } +} + +function syncDocsHashNavigation(options = {}) { + if (!docsHashLinks.length) return; + const activeHash = location.hash || "#overview"; + const isKanjiAppPage = activeHash.startsWith("#kanji"); + const isParallelPage = activeHash.startsWith("#parallel"); + const isPipelinePage = activeHash.startsWith("#pipeline-"); + const activePipelineSidebarHash = activeHash.startsWith("#pipeline-input-") + ? "#pipeline-studio-input-docs" + : activeHash; + document.body.classList.toggle("docsAppPage", isKanjiAppPage); + document.body.classList.toggle("docsPipelinePage", isPipelinePage); + document.body.classList.toggle("docsParallelPage", isParallelPage); + docsHashLinks.forEach((link) => { + const href = link.getAttribute("href") || ""; + const isSidebarKanjiParent = href === "#kanji-a-day" && link.classList.contains("docsAppParentLink"); + const isSidebarParallelParent = href === "#parallel-execution" && link.classList.contains("docsParallelParentLink"); + const isParallelParentActive = isSidebarParallelParent && isParallelPage; + const isPipelineSubsectionActive = isPipelinePage && href === activePipelineSidebarHash; + link.classList.toggle("active", (href === activeHash && !isSidebarKanjiParent) || isParallelParentActive || isPipelineSubsectionActive); + }); + if (options.scrollToHash && (isKanjiAppPage || isPipelinePage || isParallelPage)) { + window.requestAnimationFrame(() => { + const target = document.querySelector(activeHash); + if (target) target.scrollIntoView({ block: "start" }); + }); + } +} + +function refreshSavedQueryOptions() { + if (!savedQuerySelect) return; + const options = ['']; + for (const query of savedQueries) { + options.push(``); + } + savedQuerySelect.innerHTML = options.join(""); + if (activeQueryId) savedQuerySelect.value = activeQueryId; +} + +function queryFilterPayload() { + return { + status: activeFilter, + tracker: activeTracker, + package: activePackage, + role: activeRole, + agent: activeAgent, + search: searchTerm, + updatedFrom: activeUpdatedFrom, + updatedTo: activeUpdatedTo, + evidence: activeEvidence, + risk: activeRisk, + perPage, + }; +} + +async function loadSavedQueries() { + try { + const payload = await apiGetJson(`${API_BASE}/queries`); + savedQueries = Array.isArray(payload.queries) ? payload.queries : []; + } catch { + savedQueries = []; + } + refreshSavedQueryOptions(); +} + +function queryToFilterState(rawFilters) { + let filters = {}; + if (typeof rawFilters === "string" && rawFilters.trim()) { + try { + filters = JSON.parse(rawFilters); + } catch { + filters = {}; + } + } else if (rawFilters && typeof rawFilters === "object") { + filters = rawFilters; + } + return { + status: String(filters.status || "open"), + tracker: String(filters.tracker || filters.trackerFilter || ""), + package: String(filters.package || ""), + role: String(filters.role || ""), + agent: String(filters.agent || ""), + search: String(filters.search || ""), + updatedFrom: String(filters.updatedFrom || filters.updated_from || ""), + updatedTo: String(filters.updatedTo || filters.updated_to || ""), + evidence: String(filters.evidence || ""), + risk: String(filters.risk || ""), + }; +} + +function applyQueryFilters(query) { + const nextState = queryToFilterState(query?.filters || query?.query?.filters || {}); + activeQueryId = String(query?.id || query?.query?.id || ""); + applyFilterState(nextState); + if (query?.name && queryNameInput) queryNameInput.value = query.name; + page = 1; + persistFilterState(); + setLocationFromState(); + void withSpinner(loadIssues()); +} + +async function saveCurrentQuery() { + if (!queryNameInput || !savedQuerySelect) return; + const name = queryNameInput.value.trim() || "Custom filter"; + const payload = { + name, + filters: JSON.stringify(queryFilterPayload()), + is_default: false, + }; + const response = await fetch(`${API_BASE}/queries`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + const query = result.query || {}; + activeQueryId = String(query.id || ""); + persistFilterState(); + await loadSavedQueries(); + savedQuerySelect.value = activeQueryId; +} + +function exportIssues(format) { + const rows = filteredIssueRows(); + const issueSet = rows.map((issue) => ({ + id: issue.id, + subject: issue.subject, + tracker: issue.tracker, + status: issue.status, + priority: issue.priority, + assignee: issue.assignee, + agent: issue.agent, + role: issue.role, + package: issue.package, + node: issue.node, + updated_on: issue.updated_on, + validation_report: issue.validation_report || "", + })); + let content = ""; + let mime = "application/json"; + let filename = `agent-work-export-${Date.now()}.json`; + if (format === "csv") { + const headers = Object.keys(issueSet[0] || { + id: "", + subject: "", + tracker: "", + status: "", + priority: "", + assignee: "", + agent: "", + role: "", + package: "", + node: "", + updated_on: "", + validation_report: "", + }); + const csvEscape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; + content = [ + headers.join(","), + ...issueSet.map((row) => headers.map((header) => csvEscape(row[header])).join(",")), + ].join("\n"); + mime = "text/csv"; + filename = `agent-work-export-${Date.now()}.csv`; + } else { + content = JSON.stringify(issueSet, null, 2); + } + const blob = new Blob([content], { type: `${mime};charset=utf-8` }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function currentIssuePayloadFromDetail() { + const issue = detailPayload?.issue || {}; + const customFields = detailPayload?.custom_fields || {}; + return { + id: issue.id, + subject: issue.subject, + tracker: issue.tracker, + status: issue.status, priority: issue.priority, assignee: issue.assignee, agent: issue.agent, @@ -1036,10 +6021,22 @@ function currentIssuePayloadFromDetail() { async function submitIssueForm(event) { event.preventDefault(); - const payload = issueFormPayload(); const issueId = issueIdInput.value ? Number.parseInt(issueIdInput.value, 10) : null; + if (!issueId) { + const response = await fetch(`${API_BASE}/pipeline-runs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(runPipelinePayload()), + }); + const result = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); + closeIssueModal(); + await openDefaultPipelineRouteFromIssue(result); + return; + } + const payload = issueFormPayload(); const response = await fetch(issueId ? `${API_BASE}/issues/${issueId}` : `${API_BASE}/issues`, { - method: issueId ? "PATCH" : "POST", + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); @@ -1055,6 +6052,10 @@ async function submitIssueForm(event) { } } +function shouldAutoOpenPipelineRoute(payload) { + return Boolean(payload?.pipeline_route?.default && location.pathname !== "/dev-pipeline-studio"); +} + async function submitStatusTransition(event) { event.preventDefault(); const issueId = currentIssueIdFromDetail(); @@ -1542,13 +6543,14 @@ async function loadReview() { function showReview() { setNavActive("review"); document.body.classList.remove("reviewMode"); + hideSoftwareDeliveryViews(); listView.classList.add("hidden"); detailView.classList.add("hidden"); clusterView.classList.add("hidden"); consultingView.classList.add("hidden"); factoryView.classList.add("hidden"); docsView.classList.add("hidden"); - researchView.classList.add("hidden"); + hideResearchViews(); reviewView.classList.remove("hidden"); history.replaceState(null, "", "/review"); void loadReview().catch((error) => { @@ -1556,91 +6558,377 @@ function showReview() { }); } -function moveReviewSelection(delta) { - if (!reviewItems.length) return; - reviewSelectedIndex = (reviewSelectedIndex + delta + reviewItems.length) % reviewItems.length; - renderReviewQueue(); - void loadReviewDetail(selectedReviewIssueId()).catch(console.error); +function moveReviewSelection(delta) { + if (!reviewItems.length) return; + reviewSelectedIndex = (reviewSelectedIndex + delta + reviewItems.length) % reviewItems.length; + renderReviewQueue(); + void loadReviewDetail(selectedReviewIssueId()).catch(console.error); +} + +async function decideReview(decision) { + const issueId = selectedReviewIssueId(); + if (!issueId) return; + const note = ["question", "command", "unblock"].includes(decision) + ? blockerNote.value.trim() + : ""; + await fetch(`${API_BASE}/review/${issueId}/decision`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ decision, note }), + }).then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }); + if (reviewAutoAdvance.checked) { + reviewItems.splice(reviewSelectedIndex, 1); + if (reviewSelectedIndex >= reviewItems.length) reviewSelectedIndex = Math.max(0, reviewItems.length - 1); + renderReviewQueue(); + await loadReviewDetail(selectedReviewIssueId()); + } else { + await loadReview(); + } +} + +function withSpinner(promise) { + setListLoading(true); + return promise.finally(() => setListLoading(false)); +} + +async function apiGetJson(url) { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); +} + +async function showDetail(issueId) { + setNavActive("issues"); + detailIssueId = issueId; + detailContent.innerHTML = `
    Loading issue…
    `; + historyList.innerHTML = `
    Loading history…
    `; + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); + listView.classList.add("hidden"); + reviewView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + detailView.classList.remove("hidden"); + history.replaceState(null, "", `/issues/${issueId}`); + try { + const payload = await apiGetJson(`${API_BASE}/issues/${issueId}`); + if (shouldAutoOpenPipelineRoute(payload)) { + await openDefaultPipelineRouteFromIssue(payload); + return; + } + renderDetail(payload); + } catch (error) { + showDetailError(error.message); + } +} + +function showList() { + setNavActive("issues"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + listView.classList.remove("hidden"); + setLocationFromState(); + void withSpinner(loadIssues()); +} + +function showHome() { + if (!homeView) { + showList(); + return; + } + setNavActive("home"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + setOptionalHidden(homeView, false); + setOptionalHidden(softwareDeliveryHubView, true); + setOptionalHidden(devPipelineStudioView, true); + setOptionalHidden(patchSwarmView, true); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + history.replaceState(null, "", "/"); +} + +function showSoftwareDeliveryHub() { + if (!softwareDeliveryHubView) { + showCentoSection("factory"); + return; + } + if (location.hash === "#dev-pipeline-studio") { + history.replaceState(null, "", "/dev-pipeline-studio"); + showDevPipelineStudio(); + return; + } + setNavActive("software-delivery"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + softwareDeliveryHubView.querySelectorAll('.hubSidebar a').forEach((a) => { + a.classList.toggle('active', a.getAttribute('href') === '/software-delivery-hub'); + }); + setOptionalHidden(homeView, true); + setOptionalHidden(softwareDeliveryHubView, false); + setOptionalHidden(devPipelineStudioView, true); + setOptionalHidden(patchSwarmView, true); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + history.replaceState(null, "", "/software-delivery-hub"); +} + +function showDevPipelineStudio() { + if (!softwareDeliveryHubView || !devPipelineStudioView) { + showSoftwareDeliveryHub(); + return; + } + setNavActive("dev-pipeline-studio"); + document.body.classList.remove("reviewMode"); + document.body.classList.add("studioMode"); + softwareDeliveryHubView.querySelectorAll('.hubSidebar a').forEach((a) => { + a.classList.toggle('active', a.getAttribute('href') === '/dev-pipeline-studio'); + }); + setOptionalHidden(homeView, true); + setOptionalHidden(softwareDeliveryHubView, false); + setOptionalHidden(devPipelineStudioView, false); + setOptionalHidden(patchSwarmView, true); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + const pipelineControlsAlreadyReady = pipelineStudioControlsInitialized; + initPipelineStudioControls(); + if (pipelineControlsAlreadyReady) void loadPipelineStudioState(); + const hash = location.hash; + setPipelineTab(pipelineTabFromHash(hash)); + history.replaceState(null, "", `/dev-pipeline-studio${hash}`); + const cleanHash = String(hash || "").replace(/^#/, ""); + const isPipelineTabHash = Object.values(PIPELINE_TAB_HASHES).includes(cleanHash); + if (hash && !isPipelineTabHash) { + window.requestAnimationFrame(() => { + const target = document.querySelector(hash); + if (target) target.scrollIntoView({ block: "start" }); + }); + } else { + window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, left: 0 }); + setTimeout(() => window.scrollTo({ top: 0, left: 0 }), 0); + }); + } +} + +function ciCompactNumber(value) { + const number = Number(value || 0); + if (!Number.isFinite(number)) return "--"; + if (number >= 1000) return `${(number / 1000).toFixed(number >= 10000 ? 0 : 1)}k`; + return String(number); } -async function decideReview(decision) { - const issueId = selectedReviewIssueId(); - if (!issueId) return; - const note = ["question", "command", "unblock"].includes(decision) - ? blockerNote.value.trim() - : ""; - await fetch(`${API_BASE}/review/${issueId}/decision`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ decision, note }), - }).then((response) => { - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json(); +function setCiText(selector, value) { + document.querySelectorAll(selector).forEach((element) => { + element.textContent = value; }); - if (reviewAutoAdvance.checked) { - reviewItems.splice(reviewSelectedIndex, 1); - if (reviewSelectedIndex >= reviewItems.length) reviewSelectedIndex = Math.max(0, reviewItems.length - 1); - renderReviewQueue(); - await loadReviewDetail(selectedReviewIssueId()); - } else { - await loadReview(); - } } -function withSpinner(promise) { - setListLoading(true); - return promise.finally(() => setListLoading(false)); +function setCiHealth(name, pct, label) { + const normalized = Math.max(0, Math.min(100, Number(pct || 0))); + document.querySelectorAll(`[data-ci-health="${name}"]`).forEach((element) => { + element.style.width = `${normalized}%`; + }); + setCiText(`[data-ci-health-label="${name}"]`, label || `${normalized}%`); } -async function apiGetJson(url) { - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); +function renderCodebaseInventory(payload) { + if (!payload) return; + const health = payload.health || {}; + const graph = payload.graph || {}; + const capabilities = Array.isArray(payload.capabilities) ? payload.capabilities : []; + const routes = Array.isArray(payload.routes) ? payload.routes : []; + const datastores = Array.isArray(payload.datastores) ? payload.datastores : []; + const uncategorized = Array.isArray(health.uncategorized_files) ? health.uncategorized_files.length : 0; + const routeHealth = Math.min(100, routes.length * 8); + const testHealth = Math.min(100, (Number(health.test_file_count || 0) / Math.max(1, Number(health.script_count || 1))) * 100); + + setCiText('[data-ci-metric="scriptCount"]', `${ciCompactNumber(health.script_count)} scripts indexed`); + setCiText('[data-ci-metric="capabilityCount"]', ciCompactNumber(capabilities.length || graph.nodes?.length)); + setCiText('[data-ci-metric="routeCount"]', ciCompactNumber(routes.length)); + setCiText('[data-ci-metric="dataFileCount"]', ciCompactNumber(health.data_file_count || datastores.length)); + setCiText('[data-ci-metric="testCount"]', ciCompactNumber(health.test_file_count)); + setCiText('[data-ci-metric="lineCount"]', `${ciCompactNumber(health.total_lines)} lines`); + setCiText("[data-ci-repo-state]", uncategorized ? "Needs map" : "Indexed"); + + const repoState = document.querySelector("[data-ci-repo-state]"); + if (repoState) repoState.classList.toggle("clean", uncategorized === 0); + setCiHealth("docstrings", health.with_docstring_pct || 0); + setCiHealth("tests", testHealth, `${Math.round(testHealth)}%`); + setCiHealth("routes", routeHealth, `${routes.length}`); +} + +function normalizeInspectorPayload(payload) { + if (!payload || payload.error) return null; + const extension = String(payload.extension || "").replace(/^\./, ""); + const language = extension ? extension.toUpperCase() : "File"; + const routes = Array.isArray(codebaseIntelligencePayload?.routes) ? codebaseIntelligencePayload.routes.slice(0, 5) : []; + const apiRoutes = routes.map((route) => ({ + method: Array.isArray(route.methods) ? route.methods[0] : "GET", + path: route.prefix || "/", + label: route.description || route.module || "Route", + })); + const debt = Array.isArray(payload.health?.issues) && payload.health.issues.length + ? payload.health.issues + : ["No high-risk inspector issues detected for this file."]; + return { + path: payload.path, + size_kb: ((Number(payload.size_bytes || 0)) / 1024).toFixed(1), + loc: payload.lines || 0, + modified: "Local workspace", + language, + purpose: payload.docstring || `Repository file inspected by Codebase Intelligence (${payload.path}).`, + api_routes: apiRoutes, + datastore: { + type: "repository", + path: payload.path, + }, + tech_debt: debt, + ai_assistant: { + prompt: "Explain this file and its connected routes", + answer: `This inspector entry summarizes ${payload.path}, including line count, parsed symbols, imports, and local capability mapping.`, + references: [{ path: payload.path, lines: payload.lines || 1 }], + }, + }; +} + +async function loadCodebaseIntelligencePayload() { + try { + codebaseIntelligencePayload = await apiGetJson(`${API_BASE}/codebase-intelligence`); + renderCodebaseInventory(codebaseIntelligencePayload); + } catch (error) { + setCiText('[data-ci-metric="scriptCount"]', "Inventory unavailable"); + setCiText('[data-ci-metric="lineCount"]', error.message); } - return response.json(); } -async function showDetail(issueId) { - setNavActive("issues"); - detailIssueId = issueId; - detailContent.innerHTML = `
    Loading issue…
    `; - historyList.innerHTML = `
    Loading history…
    `; +async function initCodebaseIntelligence() { + if (codebaseIntelligenceInitialized) return; + codebaseIntelligenceInitialized = true; + + if (ciGraphMount && window.CodebaseIntelligenceGraph?.init) { + ciGraphMount.innerHTML = ""; + window.CodebaseIntelligenceGraph.init(ciGraphMount); + } + + await loadCodebaseIntelligencePayload(); + + if (window.CIpanels) { + let inspectorPayload = null; + if (window.CIpanels.loadInspectorData) { + inspectorPayload = normalizeInspectorPayload(await window.CIpanels.loadInspectorData("scripts/agent_work_app.py")); + } + window.CIpanels.mountInspectorPanel?.(ciInspectorMount, inspectorPayload || undefined); + window.CIpanels.mountAskCentoPanel?.(ciAskMount, { + context: "Cento repository", + example_prompt: "Which console routes connect to Agent Work and Factory?", + answer: "Codebase Intelligence maps registered API routes, local script capabilities, and data stores so route ownership and dependencies stay visible while working in the Research Center.", + references: [ + { path: "scripts/agent_work_app.py", lines: 2542 }, + { path: "scripts/codebase_intelligence.py", lines: 472 }, + { path: "templates/agent-work-app/app.js", lines: 1 }, + ], + extra_refs: 2, + }); + } +} + +function showResearchCenter() { + setNavActive("research"); document.body.classList.remove("reviewMode"); - listView.classList.add("hidden"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); clusterView.classList.add("hidden"); consultingView.classList.add("hidden"); factoryView.classList.add("hidden"); docsView.classList.add("hidden"); - researchView.classList.add("hidden"); - detailView.classList.remove("hidden"); - history.replaceState(null, "", `/issues/${issueId}`); - try { - const payload = await apiGetJson(`${API_BASE}/issues/${issueId}`); - renderDetail(payload); - } catch (error) { - showDetailError(error.message); + setOptionalHidden(codebaseIntelligenceView, true); + setOptionalHidden(researchView, false); + history.replaceState(null, "", `/research-center${location.hash || ""}`); + if (location.hash) { + window.requestAnimationFrame(() => { + const target = document.querySelector(location.hash); + if (target) target.scrollIntoView({ block: "start" }); + }); } } -function showList() { - setNavActive("issues"); +function showCodebaseIntelligence() { + if (!codebaseIntelligenceView) { + showResearchCenter(); + return; + } + setNavActive("codebase-intelligence"); document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); reviewView.classList.add("hidden"); detailView.classList.add("hidden"); + listView.classList.add("hidden"); clusterView.classList.add("hidden"); consultingView.classList.add("hidden"); factoryView.classList.add("hidden"); docsView.classList.add("hidden"); - researchView.classList.add("hidden"); - listView.classList.remove("hidden"); - setLocationFromState(); - void withSpinner(loadIssues()); + setOptionalHidden(researchView, true); + setOptionalHidden(codebaseIntelligenceView, false); + history.replaceState(null, "", "/codebase-intelligence"); + window.scrollTo({ top: 0, left: 0 }); + void initCodebaseIntelligence().catch((error) => { + if (ciGraphMount) ciGraphMount.innerHTML = `
    ${escapeHtml(error.message)}
    `; + }); } function showCentoSection(route) { + if (route === "research") { + showResearchCenter(); + return; + } + if (route === "codebase-intelligence") { + showCodebaseIntelligence(); + return; + } setNavActive(route); document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); reviewView.classList.add("hidden"); detailView.classList.add("hidden"); listView.classList.add("hidden"); @@ -1648,18 +6936,15 @@ function showCentoSection(route) { consultingView.classList.toggle("hidden", route !== "consulting"); factoryView.classList.toggle("hidden", route !== "factory"); docsView.classList.toggle("hidden", route !== "docs"); - researchView.classList.toggle("hidden", route !== "research"); + hideResearchViews(); if (route === "factory") { history.replaceState(null, "", "/factory"); void loadFactory(); return; } - if (route === "research") { - history.replaceState(null, "", "/research-center"); - return; - } const hash = route === "docs" ? location.hash : ""; history.replaceState(null, "", `/${route}${hash}`); + if (route === "docs") syncDocsHashNavigation({ scrollToHash: true }); } async function loadIssues() { @@ -1864,6 +7149,449 @@ async function loadFactory() { } } +function patchSwarmRunPathId() { + const match = location.pathname.match(/^\/patch-swarm\/runs\/([^/]+)/); + return match ? decodeURIComponent(match[1]) : ""; +} + +function patchSwarmStatusText(value, fallback = "-") { + const clean = String(value || fallback || "").trim(); + return clean ? clean.replaceAll("_", " ") : fallback; +} + +function patchSwarmRepoCanStart(repo) { + return Boolean(repo?.can_start) && Number(repo?.protected_dirty_count || 0) === 0; +} + +function patchSwarmRepoDirtyLabel(repo) { + if (!repo) return ""; + if (!repo.dirty) return "clean"; + const count = Number(repo.dirty_count || 0); + return `${count} dirty path${count === 1 ? "" : "s"}`; +} + +function patchSwarmRepoOptionLabel(repo) { + const state = patchSwarmRepoCanStart(repo) + ? `startable · ${patchSwarmRepoDirtyLabel(repo)}` + : `blocked${Number(repo.protected_dirty_count || 0) ? " · protected dirty" : ""}`; + return `${repo.name || repo.path} · ${state} · ${repo.path}`; +} + +function patchSwarmSortedRepos(repos) { + return [...(repos || [])].sort((left, right) => { + const leftStart = patchSwarmRepoCanStart(left) ? 0 : 1; + const rightStart = patchSwarmRepoCanStart(right) ? 0 : 1; + if (leftStart !== rightStart) return leftStart - rightStart; + return `${left.name || ""}\u0000${left.path || ""}`.localeCompare(`${right.name || ""}\u0000${right.path || ""}`); + }); +} + +function patchSwarmSelectedRepo() { + const selected = patchSwarmRepoSelect?.value || ""; + return patchSwarmRepos.find((repo) => repo.path === selected) || patchSwarmRepos[0] || null; +} + +function patchSwarmTaskReady() { + return Boolean(patchSwarmTask?.value.trim()); +} + +function patchSwarmCanSubmitStart() { + return patchSwarmRepoCanStart(patchSwarmSelectedRepo()) && patchSwarmTaskReady(); +} + +function patchSwarmSetStartStatus(state, message = "") { + if (!patchSwarmStartStatus) return; + const labels = { + ready: "Ready", + blocked: "Blocked", + task_required: "Task required", + starting: "Starting", + run_created: "Run created", + failed: "Failed", + }; + patchSwarmStartStatus.dataset.state = state; + patchSwarmStartStatus.innerHTML = `${escapeHtml(labels[state] || state)} ${escapeHtml(message)}`; +} + +function updatePatchSwarmStartControls({ preserveStatus = false } = {}) { + const repo = patchSwarmSelectedRepo(); + const mode = patchSwarmMode?.value || "fixture"; + const fixtureMessage = mode === "fixture" + ? "Fixture mode generates local candidate receipts with no API spend." + : "Live gated mode requires backend budget gates before real provider use."; + if (patchSwarmStartHint) { + patchSwarmStartHint.textContent = `${fixtureMessage} Generation does not mutate the selected repo.`; + } + if (!repo) { + if (patchSwarmStartButton) { + patchSwarmStartButton.disabled = true; + patchSwarmStartButton.title = "No local Git repos discovered."; + } + if (!preserveStatus) patchSwarmSetStartStatus("blocked", "No local Git repos were discovered."); + return false; + } + if (!patchSwarmRepoCanStart(repo)) { + const protectedPaths = Array.isArray(repo.protected_dirty) ? repo.protected_dirty.join(", ") : ""; + if (patchSwarmStartButton) { + patchSwarmStartButton.disabled = true; + patchSwarmStartButton.title = protectedPaths ? `Clear protected dirty paths first: ${protectedPaths}` : "Selected repo is blocked."; + } + if (!preserveStatus) { + patchSwarmSetStartStatus( + "blocked", + protectedPaths ? `Clear protected dirty paths first: ${protectedPaths}` : "The selected repo cannot start a run.", + ); + } + return false; + } + if (!patchSwarmTaskReady()) { + if (patchSwarmStartButton) { + patchSwarmStartButton.disabled = true; + patchSwarmStartButton.title = "Enter a task brief before starting."; + } + if (!preserveStatus) patchSwarmSetStartStatus("task_required", "Enter a task brief to start a fixture run."); + return false; + } + if (patchSwarmStartButton) { + patchSwarmStartButton.disabled = false; + patchSwarmStartButton.title = ""; + } + if (!preserveStatus) patchSwarmSetStartStatus("ready", "Start a safe fixture run for the selected repo."); + return true; +} + +function renderPatchSwarmRepoState() { + if (!patchSwarmRepoState) return; + const repo = patchSwarmSelectedRepo(); + if (!repo) { + patchSwarmRepoState.textContent = "No local Git repos discovered."; + patchSwarmRepoState.classList.add("blocked"); + patchSwarmRepoState.classList.remove("ready"); + updatePatchSwarmStartControls(); + return; + } + const canStart = patchSwarmRepoCanStart(repo); + const protectedPaths = Array.isArray(repo.protected_dirty) ? repo.protected_dirty : []; + const protectedText = protectedPaths.length ? `Protected dirty: ${protectedPaths.join(", ")}` : "No protected dirty paths."; + patchSwarmRepoState.innerHTML = ` + ${escapeHtml(repo.name || repo.path)} + ${escapeHtml(repo.branch || "unknown")} · ${escapeHtml(patchSwarmRepoDirtyLabel(repo))} · ${escapeHtml(canStart ? "startable" : "blocked")} + ${escapeHtml(protectedText)} + `; + patchSwarmRepoState.classList.toggle("blocked", !canStart); + patchSwarmRepoState.classList.toggle("ready", canStart); + updatePatchSwarmStartControls(); +} + +function renderPatchSwarmRepos(payload) { + const currentSelection = patchSwarmRepoSelect?.value || ""; + patchSwarmRepos = patchSwarmSortedRepos(Array.isArray(payload?.repos) ? payload.repos : []); + if (!patchSwarmRepoSelect) return; + patchSwarmRepoSelect.innerHTML = patchSwarmRepos.length + ? patchSwarmRepos.map((repo) => ``).join("") + : ``; + const previous = patchSwarmRepos.find((repo) => repo.path === currentSelection); + const preferred = previous && patchSwarmRepoCanStart(previous) + ? previous + : patchSwarmRepos.find(patchSwarmRepoCanStart) || patchSwarmRepos[0]; + if (preferred) patchSwarmRepoSelect.value = preferred.path; + renderPatchSwarmRepoState(); +} + +async function loadPatchSwarmRepos() { + if (!patchSwarmRepoSelect) return; + patchSwarmRepoState.textContent = "Loading repositories..."; + try { + renderPatchSwarmRepos(await apiGetJson(`${API_BASE}/patch-swarm/repos`)); + } catch (error) { + patchSwarmRepoState.textContent = error.message; + patchSwarmRepoState.classList.add("blocked"); + patchSwarmSetStartStatus("failed", error.message); + if (patchSwarmStartButton) patchSwarmStartButton.disabled = true; + } +} + +function renderPatchSwarmRunList(payload) { + patchSwarmRuns = Array.isArray(payload?.runs) ? payload.runs : []; + if (!patchSwarmRunList) return; + if (!patchSwarmRuns.length) { + patchSwarmRunList.innerHTML = `
    No Patch Swarm runs yet.
    `; + return; + } + patchSwarmRunList.innerHTML = patchSwarmRuns.slice(0, 12).map((run) => { + const active = patchSwarmDetail?.run?.run_id === run.run_id ? " active" : ""; + const repo = run.selected_repo || {}; + const kind = run.run_kind || (repo.path || repo.name ? "product" : "engine"); + const isProductRun = kind === "product"; + const repoLabel = repo.name || repo.path || "legacy engine-only run"; + const status = patchSwarmStatusText(run.status, "unknown"); + const approval = patchSwarmStatusText(run.approval_status, "not approved"); + const apply = patchSwarmStatusText(run.apply_status, "not applied"); + return ` + + `; + }).join(""); +} + +async function loadPatchSwarmRuns() { + if (!patchSwarmRunList) return; + try { + const payload = await apiGetJson(`${API_BASE}/patch-swarm/runs`); + renderPatchSwarmRunList(payload); + } catch (error) { + patchSwarmRunList.innerHTML = `
    ${escapeHtml(error.message)}
    `; + } +} + +function patchSwarmSelectedCandidate() { + const candidates = Array.isArray(patchSwarmDetail?.candidates) ? patchSwarmDetail.candidates : []; + return candidates.find((candidate) => candidate.id === patchSwarmSelectedCandidateId) || null; +} + +function patchSwarmSelectedValidatedCandidates() { + const candidates = Array.isArray(patchSwarmDetail?.candidates) ? patchSwarmDetail.candidates : []; + const selectedIds = new Set((patchSwarmDetail?.integration?.selected_candidates || []).map(String)); + const selected = candidates.filter((candidate) => selectedIds.has(String(candidate.id))); + return selected.filter((candidate) => String(candidate.status || "") === "validated"); +} + +function patchSwarmCanApproveRun() { + const selectedIds = new Set((patchSwarmDetail?.integration?.selected_candidates || []).map(String)); + return selectedIds.size > 0 && patchSwarmSelectedValidatedCandidates().length === selectedIds.size; +} + +function patchSwarmActionGates() { + return patchSwarmDetail?.action_gates || patchSwarmDetail?.run?.action_gates || {}; +} + +function updatePatchSwarmReviewActions() { + const gates = patchSwarmActionGates(); + if (patchSwarmApproveButton) { + patchSwarmApproveButton.disabled = !gates.can_approve; + patchSwarmApproveButton.title = gates.can_approve ? "" : (gates.approve_disabled_reason || "Approval is disabled by the run contract."); + } + if (patchSwarmApplyButton) { + patchSwarmApplyButton.disabled = !gates.can_apply; + patchSwarmApplyButton.title = gates.can_apply ? "" : (gates.apply_disabled_reason || "Apply is disabled by the run contract."); + } + if (patchSwarmRejectButton) { + patchSwarmRejectButton.disabled = !gates.can_reject; + patchSwarmRejectButton.title = gates.can_reject ? "" : (gates.reject_disabled_reason || "Reject is disabled by the run contract."); + } +} + +function setPatchSwarmDetailPanelsVisible(visible) { + patchSwarmDetailEmpty?.classList.toggle("hidden", visible); + patchSwarmStatsPanel?.classList.toggle("hidden", !visible); + patchSwarmReviewGrid?.classList.toggle("hidden", !visible); + patchSwarmEvidence?.classList.toggle("hidden", !visible); +} + +function renderPatchSwarmEmptyDetail() { + patchSwarmDetail = null; + patchSwarmSelectedCandidateId = ""; + const title = document.querySelector("#patch-swarm-detail-title"); + if (title) title.textContent = "No run selected"; + if (patchSwarmRunSubtitle) { + patchSwarmRunSubtitle.textContent = "Start a fixture run with a startable repo, or select a recent product run."; + } + if (patchSwarmCandidateList) patchSwarmCandidateList.innerHTML = `
    No candidates loaded.
    `; + renderPatchSwarmDiff(null); + setPatchSwarmDetailPanelsVisible(false); + updatePatchSwarmReviewActions(); + renderPatchSwarmRunList({ runs: patchSwarmRuns }); +} + +function renderPatchSwarmDiff(candidate) { + if (!patchSwarmDiffPreview) return; + if (!candidate) { + patchSwarmSelectedCandidateId = ""; + patchSwarmDiffTitle.textContent = "Diff Preview"; + patchSwarmDiffMeta.textContent = "Select a candidate."; + patchSwarmDiffPreview.textContent = "No diff selected."; + updatePatchSwarmReviewActions(); + return; + } + patchSwarmSelectedCandidateId = candidate.id || ""; + patchSwarmDiffTitle.textContent = candidate.id || "Candidate"; + patchSwarmDiffMeta.textContent = `${candidate.provider || "provider"} · score ${candidate.score ?? "-"} · ${candidate.status || "unknown"}`; + patchSwarmDiffPreview.textContent = candidate.diff_preview || "Diff preview unavailable."; + document.querySelectorAll("[data-patch-swarm-candidate]").forEach((row) => { + row.classList.toggle("active", row.dataset.patchSwarmCandidate === patchSwarmSelectedCandidateId); + }); + updatePatchSwarmReviewActions(); +} + +function renderPatchSwarmCandidates(candidates) { + if (!patchSwarmCandidateList) return; + if (!candidates.length) { + patchSwarmCandidateList.innerHTML = `
    No candidates loaded.
    `; + renderPatchSwarmDiff(null); + return; + } + const selectedIds = new Set((patchSwarmDetail?.integration?.selected_candidates || []).map(String)); + patchSwarmCandidateList.innerHTML = candidates.slice(0, 80).map((candidate) => { + const selected = selectedIds.has(String(candidate.id)); + const rejected = candidate.decision === "rejected"; + return ` + + `; + }).join(""); + if (!patchSwarmSelectedCandidateId || !candidates.some((candidate) => candidate.id === patchSwarmSelectedCandidateId)) { + patchSwarmSelectedCandidateId = candidates[0]?.id || ""; + } + renderPatchSwarmDiff(patchSwarmSelectedCandidate()); +} + +function patchSwarmArtifactLink(label, path) { + if (!path) return `${escapeHtml(label)} pending`; + return `${escapeHtml(label)}`; +} + +function patchSwarmEvidenceValue(label, value) { + return `${escapeHtml(label)}${escapeHtml(value || "pending")}`; +} + +function renderPatchSwarmDetail(payload) { + patchSwarmDetail = payload; + const run = payload?.run || {}; + const candidates = Array.isArray(payload?.candidates) ? payload.candidates : []; + document.querySelector("#patch-swarm-detail-title").textContent = run.run_id || "No run selected"; + patchSwarmRunSubtitle.textContent = run.task_brief || "Start or select a run to inspect ranked candidates."; + patchSwarmCandidateCount.textContent = String(run.candidate_count || 0); + patchSwarmSelectedCount.textContent = String(run.selected_count || 0); + patchSwarmValidationStatus.textContent = run.validation || "unknown"; + patchSwarmCost.textContent = `$${Number(run.estimated_cost_usd || 0).toFixed(6)}`; + patchSwarmApprovalStatus.textContent = run.approval_status || "not_approved"; + patchSwarmSelectedCandidateId = patchSwarmSelectedCandidateId || candidates[0]?.id || ""; + setPatchSwarmDetailPanelsVisible(true); + renderPatchSwarmCandidates(candidates); + const artifacts = run.artifacts || {}; + const repo = run.selected_repo || {}; + const applyReceipt = payload?.apply_receipt || {}; + const noMutationReceipt = artifacts.no_mutation_apply || artifacts.no_mutation || ""; + const consoleHref = run.run_id ? `/patch-swarm/runs/${encodeURIComponent(run.run_id)}/console` : ""; + patchSwarmEvidence.innerHTML = ` + ${consoleHref ? `Status console` : ""} + ${patchSwarmArtifactLink("Decision report", artifacts.decision_report || "")} + ${patchSwarmArtifactLink("Candidate index", artifacts.candidate_index || "")} + ${patchSwarmEvidenceValue("Repo", repo.path || run.run_dir || "")} + ${patchSwarmEvidenceValue("Worktree", applyReceipt.worktree || "")} + ${patchSwarmArtifactLink("No-mutation receipt", noMutationReceipt)} + `; + updatePatchSwarmReviewActions(); + renderPatchSwarmRunList({ runs: patchSwarmRuns }); +} + +async function loadPatchSwarmDetail(runId) { + if (!runId) return; + patchSwarmSelectedCandidateId = ""; + const payload = await apiGetJson(`${API_BASE}/patch-swarm/runs/${encodeURIComponent(runId)}`); + renderPatchSwarmDetail(payload); + history.replaceState(null, "", `/patch-swarm/runs/${encodeURIComponent(runId)}`); +} + +async function submitPatchSwarmRun(event) { + event.preventDefault(); + const repo = patchSwarmSelectedRepo(); + if (!repo || !patchSwarmCanSubmitStart()) { + updatePatchSwarmStartControls(); + return; + } + patchSwarmSetStartStatus("starting", "Creating the fixture run and candidate receipts..."); + if (patchSwarmStartButton) patchSwarmStartButton.disabled = true; + const response = await fetch(`${API_BASE}/patch-swarm/runs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repo_path: repo.path, + task_brief: patchSwarmTask.value.trim(), + candidate_target: Number.parseInt(patchSwarmCandidateTarget.value, 10) || 30, + max_parallel_agents: Number.parseInt(patchSwarmMaxAgents.value, 10) || 3, + mode: patchSwarmMode.value || "fixture", + providers: patchSwarmProviders.value || "codex-exec,claude-code,api-openai", + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + patchSwarmSetStartStatus("failed", payload.error || `HTTP ${response.status}`); + updatePatchSwarmStartControls({ preserveStatus: true }); + return; + } + patchSwarmSetStartStatus("run_created", `Run ${payload.run?.run_id || ""} is ready for review.`); + updatePatchSwarmStartControls({ preserveStatus: true }); + await loadPatchSwarmRuns(); + renderPatchSwarmDetail(payload); + history.replaceState(null, "", `/patch-swarm/runs/${encodeURIComponent(payload.run?.run_id || "")}`); +} + +async function patchSwarmPostAction(action, body = {}) { + const runId = patchSwarmDetail?.run?.run_id; + if (!runId) return; + const response = await fetch(`${API_BASE}/patch-swarm/runs/${encodeURIComponent(runId)}/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + patchSwarmSetStartStatus("failed", payload.error || `HTTP ${response.status}`); + return; + } + renderPatchSwarmDetail(payload); + await loadPatchSwarmRuns(); +} + +async function showPatchSwarm(runId = patchSwarmRunPathId()) { + if (!patchSwarmView) { + showSoftwareDeliveryHub(); + return; + } + setNavActive("patch-swarm"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + setOptionalHidden(homeView, true); + setOptionalHidden(softwareDeliveryHubView, true); + setOptionalHidden(devPipelineStudioView, true); + setOptionalHidden(patchSwarmView, false); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + await loadPatchSwarmRepos(); + await loadPatchSwarmRuns(); + if (runId) { + await loadPatchSwarmDetail(runId).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); + renderPatchSwarmEmptyDetail(); + }); + } else { + renderPatchSwarmEmptyDetail(); + history.replaceState(null, "", "/patch-swarm"); + } +} + async function loadQueriesIntoSelect() { if (!savedQuerySelect) { savedQueries = []; @@ -1932,11 +7660,61 @@ document.querySelectorAll("[data-modal-close]").forEach((button) => { button.addEventListener("click", closeIssueModal); }); -newIssueButton.addEventListener("click", () => openIssueModal()); -headerNewIssueButton.addEventListener("click", () => openIssueModal()); +newIssueButton.addEventListener("click", () => void openRunPipelineModal()); +headerNewIssueButton.addEventListener("click", () => void openRunPipelineModal()); +quickRunPipelineButton?.addEventListener("click", () => void openRunPipelineModal()); +patchSwarmForm?.addEventListener("submit", (event) => void submitPatchSwarmRun(event).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); + updatePatchSwarmStartControls({ preserveStatus: true }); +})); +patchSwarmRefreshRepos?.addEventListener("click", () => void loadPatchSwarmRepos()); +patchSwarmRepoSelect?.addEventListener("change", renderPatchSwarmRepoState); +patchSwarmTask?.addEventListener("input", () => updatePatchSwarmStartControls()); +patchSwarmMode?.addEventListener("change", () => updatePatchSwarmStartControls()); +patchSwarmRunList?.addEventListener("click", (event) => { + const button = event.target.closest("[data-patch-swarm-run]"); + if (!button) return; + void loadPatchSwarmDetail(button.dataset.patchSwarmRun || "").catch((error) => { + patchSwarmSetStartStatus("failed", error.message); + }); +}); +patchSwarmCandidateList?.addEventListener("click", (event) => { + const button = event.target.closest("[data-patch-swarm-candidate]"); + if (!button) return; + const candidate = (patchSwarmDetail?.candidates || []).find((item) => item.id === button.dataset.patchSwarmCandidate); + renderPatchSwarmDiff(candidate); +}); +patchSwarmApproveButton?.addEventListener("click", () => void patchSwarmPostAction("approve", {}).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); +})); +patchSwarmApplyButton?.addEventListener("click", () => void patchSwarmPostAction("apply", { limit: 1, validate_each: true, use_factory: true }).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); +})); +patchSwarmRejectButton?.addEventListener("click", () => { + const candidate = patchSwarmSelectedCandidate(); + if (!candidate) return; + void patchSwarmPostAction("reject", { candidate_ids: [candidate.id], reason: "Rejected in Patch Swarm review." }).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); + }); +}); detailEditButton.addEventListener("click", () => { if (detailPayload?.issue) openIssueModal(currentIssuePayloadFromDetail()); }); +runPipelineTemplateSelect?.addEventListener("change", () => { + currentRunPipelineTemplateId = runPipelineTemplateSelect.value || currentRunPipelineTemplateId; + if (pipelineTemplateSelect && Array.from(pipelineTemplateSelect.options).some((option) => option.value === currentRunPipelineTemplateId)) { + pipelineTemplateSelect.value = currentRunPipelineTemplateId; + } + const projectId = runPipelineProjectForTemplate(currentRunPipelineTemplateId); + if (pipelineProjectSelect && Array.from(pipelineProjectSelect.options).some((option) => option.value === projectId)) { + pipelineProjectSelect.value = projectId; + } + renderRunPipelineInputCards(); +}); +runPipelineInputCards?.addEventListener("change", (event) => { + const control = event.target.closest("[data-run-pipeline-config]"); + if (control) syncRunPipelineStructuredConfig(control); +}); issueForm.addEventListener("submit", (event) => void submitIssueForm(event).catch((error) => showDetailError(error.message))); statusForm.addEventListener("submit", (event) => void submitStatusTransition(event).catch((error) => showDetailError(error.message))); @@ -2084,7 +7862,15 @@ backButton.addEventListener("click", showList); window.addEventListener("popstate", () => { syncStateFromLocation(); const match = location.pathname.match(/^\/issues\/(\d+)/); - if (location.pathname === "/review") { + if (location.pathname === "/") { + showHome(); + } else if (location.pathname === "/software-delivery-hub") { + showSoftwareDeliveryHub(); + } else if (location.pathname === "/patch-swarm" || location.pathname.startsWith("/patch-swarm/runs/")) { + void showPatchSwarm(); + } else if (location.pathname === "/dev-pipeline-studio") { + showDevPipelineStudio(); + } else if (location.pathname === "/review") { showReview(); } else if (location.pathname === "/cluster") { showCentoSection("cluster"); @@ -2093,20 +7879,34 @@ window.addEventListener("popstate", () => { } else if (location.pathname === "/factory") { showCentoSection("factory"); } else if (location.pathname === "/research-center") { - showCentoSection("research"); + showResearchCenter(); + } else if (location.pathname === "/codebase-intelligence") { + showCodebaseIntelligence(); } else if (location.pathname === "/docs") { showCentoSection("docs"); } else if (match) { void showDetail(match[1]).catch(console.error); + } else if (location.pathname === "/issues") { + showList(); } else { - void withSpinner(loadIssues()); + showHome(); + } +}); + +window.addEventListener("hashchange", () => { + if (location.pathname === "/dev-pipeline-studio") { + setPipelineTab(pipelineTabFromHash(location.hash)); + return; } + syncDocsHashNavigation({ scrollToHash: true }); }); async function boot() { syncStateFromLocation(); setNavActive(); await loadQueriesIntoSelect(); + capturePrefilledIssuePromptFromUrl(); + window.setTimeout(openPrefilledIssueModalFromUrl, 0); if (agentSummary && agentCards) { await loadAgents(); window.setInterval(loadAgents, 30000); @@ -2115,6 +7915,22 @@ async function boot() { showReview(); return; } + if (location.pathname === "/") { + showHome(); + return; + } + if (location.pathname === "/software-delivery-hub") { + showSoftwareDeliveryHub(); + return; + } + if (location.pathname === "/patch-swarm" || location.pathname.startsWith("/patch-swarm/runs/")) { + await showPatchSwarm(); + return; + } + if (location.pathname === "/dev-pipeline-studio") { + showDevPipelineStudio(); + return; + } if (location.pathname === "/cluster") { showCentoSection("cluster"); return; @@ -2128,7 +7944,11 @@ async function boot() { return; } if (location.pathname === "/research-center") { - showCentoSection("research"); + showResearchCenter(); + return; + } + if (location.pathname === "/codebase-intelligence") { + showCodebaseIntelligence(); return; } if (location.pathname === "/docs") { @@ -2143,7 +7963,12 @@ async function boot() { }); return; } - await withSpinner(loadIssues()); + if (location.pathname === "/issues" || location.pathname === "/issues/new") { + showList(); + return; + } + showHome(); } +initPipelineStudioControls(); void boot(); diff --git a/templates/agent-work-app/codebase-intelligence-graph.js b/templates/agent-work-app/codebase-intelligence-graph.js new file mode 100644 index 0000000..36f2fe1 --- /dev/null +++ b/templates/agent-work-app/codebase-intelligence-graph.js @@ -0,0 +1,557 @@ +// Capability-map dependency graph for Cento Console. +// Exposes window.CodebaseIntelligenceGraph = { init(containerEl) }. +(function () { + 'use strict'; + + var NODE_W = 162; + var NODE_H = 44; + var GRAPH_W = 1020; + var GRAPH_H = 660; + var MINIMAP_W = 190; + var MINIMAP_H = 124; + + var TECH_COLORS = { + shell: '#ffb02e', + python: '#4dd7d1', + go: '#61afef', + html: '#ff5a00', + css: '#c084fc', + js: '#e5c07b', + json: '#a89c91', + sqlite: '#10b66a', + swift: '#ff6b9d', + }; + + var NODES = [ + { id: 'cli', label: 'Cento CLI', tech: 'shell', x: 100, y: 80 }, + { id: 'mobile', label: 'Mobile / Watch Apps', tech: 'swift', x: 510, y: 80 }, + { id: 'tui', label: 'Bubble Tea TUIs', tech: 'go', x: 870, y: 80 }, + { id: 'mcp', label: 'MCP Server', tech: 'python', x: 200, y: 210 }, + { id: 'bridge', label: 'Cluster Bridge', tech: 'go', x: 480, y: 210 }, + { id: 'console', label: 'Cento Console', tech: 'js', x: 780, y: 210 }, + { id: 'taskstream', label: 'Taskstream', tech: 'python', x: 130, y: 340 }, + { id: 'factory', label: 'Factory', tech: 'python', x: 400, y: 340 }, + { id: 'templates', label: 'Templates', tech: 'html', x: 660, y: 340 }, + { id: 'docs', label: 'Docs', tech: 'html', x: 910, y: 340 }, + { id: 'crm', label: 'CRM / Consulting', tech: 'python', x: 120, y: 470 }, + { id: 'storage', label: 'Storage Catalog', tech: 'sqlite', x: 370, y: 470 }, + { id: 'tests', label: 'Tests / E2E', tech: 'python', x: 630, y: 470 }, + { id: 'workspace', label: 'Workspace / Runs', tech: 'json', x: 880, y: 470 }, + { id: 'redmine', label: 'Redmine DB', tech: 'sqlite', x: 250, y: 590 }, + ]; + + var EDGES = [ + { from: 'cli', to: 'mcp', label: 'loads' }, + { from: 'cli', to: 'bridge', label: 'registers' }, + { from: 'cli', to: 'taskstream', label: 'dispatches' }, + { from: 'cli', to: 'factory', label: 'dispatches' }, + { from: 'mobile', to: 'mcp', label: 'registers' }, + { from: 'tui', to: 'taskstream', label: 'enables' }, + { from: 'mcp', to: 'taskstream', label: 'enables' }, + { from: 'mcp', to: 'factory', label: 'enables' }, + { from: 'bridge', to: 'factory', label: 'dispatches' }, + { from: 'console', to: 'taskstream', label: 'serves UI' }, + { from: 'console', to: 'templates', label: 'loads' }, + { from: 'console', to: 'docs', label: 'serves UI' }, + { from: 'taskstream', to: 'storage', label: 'persists' }, + { from: 'factory', to: 'workspace', label: 'writes' }, + { from: 'factory', to: 'tests', label: 'enables' }, + { from: 'crm', to: 'taskstream', label: 'creates' }, + { from: 'storage', to: 'redmine', label: 'indexes' }, + { from: 'tests', to: 'factory', label: 'enables' }, + { from: 'docs', to: 'templates', label: 'documents' }, + ]; + + // Build node lookup once. + var nodeMap = {}; + NODES.forEach(function (n) { nodeMap[n.id] = n; }); + + function boxExitPoint(cx, cy, hw, hh, tx, ty) { + var dx = tx - cx; + var dy = ty - cy; + if (dx === 0 && dy === 0) return { x: cx + hw, y: cy }; + var tx1 = dx !== 0 ? hw / Math.abs(dx) : Infinity; + var ty1 = dy !== 0 ? hh / Math.abs(dy) : Infinity; + var t = Math.min(tx1, ty1); + return { x: cx + t * dx, y: cy + t * dy }; + } + + function edgeEndpoints(fn, tn) { + var hw = NODE_W / 2; + var hh = NODE_H / 2; + var src = boxExitPoint(fn.x, fn.y, hw, hh, tn.x, tn.y); + var dst = boxExitPoint(tn.x, tn.y, hw, hh, fn.x, fn.y); + return { sx: src.x, sy: src.y, ex: dst.x, ey: dst.y }; + } + + function injectStyles() { + if (document.getElementById('cig-styles')) return; + var s = document.createElement('style'); + s.id = 'cig-styles'; + s.textContent = [ + '.cig-wrapper{display:flex;flex-direction:column;gap:.6rem;height:100%}', + '.cig-filters{display:flex;flex-wrap:wrap;gap:.35rem;padding:.4rem 0;border-bottom:1px solid #2d211a}', + '.cig-filter-btn{padding:.28rem .6rem;font-size:.77rem;font-weight:700;', + 'font-family:"IBM Plex Mono",ui-monospace,monospace;', + 'border:1px solid #2d211a;background:#0b0b0a;color:#a89c91;border-radius:2px;', + 'cursor:pointer;transition:border-color .12s,color .12s}', + '.cig-filter-btn:hover{border-color:var(--cig-tc,#c74700);color:var(--cig-tc,#ff5a00)}', + '.cig-filter-btn.active{border-color:var(--cig-tc,#ff5a00);color:var(--cig-tc,#ff5a00);', + 'background:rgba(255,90,0,.08)}', + '.cig-canvas-wrap{position:relative;flex:1;min-height:480px;height:520px;', + 'border:1px solid #2d211a;background:#050403;overflow:hidden}', + '.cig-canvas{display:block;cursor:grab;outline:none}', + '.cig-canvas:focus-visible{outline:1px solid #c74700;outline-offset:-1px}', + '.cig-minimap{position:absolute;bottom:.6rem;right:.6rem;border:1px solid #2d211a;', + 'pointer-events:none;opacity:.88;border-radius:2px}', + '.cig-zoom{position:absolute;top:.6rem;right:.6rem;display:flex;flex-direction:column;gap:2px}', + '.cig-zoom button{width:2rem;height:2rem;padding:0;font-size:1.05rem;line-height:1;', + 'display:flex;align-items:center;justify-content:center;', + 'border:1px solid #2d211a;background:rgba(11,11,10,.9);color:#a89c91;', + 'border-radius:2px;cursor:pointer}', + '.cig-zoom button:hover{border-color:#c74700;color:#ff5a00}', + ].join(''); + document.head.appendChild(s); + } + + function init(containerEl) { + injectStyles(); + + // --- DOM --- + var wrapper = document.createElement('div'); + wrapper.className = 'cig-wrapper'; + + var filterBar = document.createElement('div'); + filterBar.className = 'cig-filters'; + filterBar.setAttribute('role', 'toolbar'); + filterBar.setAttribute('aria-label', 'Filter by technology'); + + var TECH_FILTERS = ['All', 'Python', 'Shell', 'Go', 'HTML', 'CSS', 'JS', 'JSON', 'SQLite']; + var activeFilter = 'All'; + + TECH_FILTERS.forEach(function (f) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'cig-filter-btn' + (f === 'All' ? ' active' : ''); + btn.textContent = f; + btn.setAttribute('aria-pressed', f === 'All' ? 'true' : 'false'); + var tc = TECH_COLORS[f.toLowerCase()]; + if (tc) btn.style.setProperty('--cig-tc', tc); + btn.addEventListener('click', function () { + activeFilter = f; + filterBar.querySelectorAll('.cig-filter-btn').forEach(function (b) { + b.classList.remove('active'); + b.setAttribute('aria-pressed', 'false'); + }); + btn.classList.add('active'); + btn.setAttribute('aria-pressed', 'true'); + render(); + }); + filterBar.appendChild(btn); + }); + + var canvasWrap = document.createElement('div'); + canvasWrap.className = 'cig-canvas-wrap'; + + var canvas = document.createElement('canvas'); + canvas.className = 'cig-canvas'; + canvas.setAttribute('tabindex', '0'); + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', 'Codebase dependency graph. Drag to pan, scroll to zoom.'); + canvasWrap.appendChild(canvas); + + var minimap = document.createElement('canvas'); + minimap.className = 'cig-minimap'; + minimap.width = MINIMAP_W; + minimap.height = MINIMAP_H; + minimap.setAttribute('aria-hidden', 'true'); + canvasWrap.appendChild(minimap); + + var zoomCtrl = document.createElement('div'); + zoomCtrl.className = 'cig-zoom'; + zoomCtrl.setAttribute('role', 'group'); + zoomCtrl.setAttribute('aria-label', 'Zoom controls'); + var btnIn = mkBtn('+', 'Zoom in'); + var btnOut = mkBtn('−', 'Zoom out'); + var btnReset = mkBtn('⊙', 'Reset zoom'); + zoomCtrl.appendChild(btnIn); + zoomCtrl.appendChild(btnOut); + zoomCtrl.appendChild(btnReset); + canvasWrap.appendChild(zoomCtrl); + + wrapper.appendChild(filterBar); + wrapper.appendChild(canvasWrap); + containerEl.appendChild(wrapper); + + // --- State --- + var zoom = 1; + var panX = 0; + var panY = 0; + var dragging = false; + var dragOrigin = null; + var hoverId = null; + + // --- Helpers --- + function screenToWorld(sx, sy) { + return { x: (sx - panX) / zoom, y: (sy - panY) / zoom }; + } + + function nodeAt(sx, sy) { + var w = screenToWorld(sx, sy); + return NODES.find(function (n) { + return w.x >= n.x - NODE_W / 2 && w.x <= n.x + NODE_W / 2 && + w.y >= n.y - NODE_H / 2 && w.y <= n.y + NODE_H / 2; + }) || null; + } + + function visible(node) { + return activeFilter === 'All' || node.tech === activeFilter.toLowerCase(); + } + + // --- Render --- + function render() { + var ctx = canvas.getContext('2d'); + var W = canvas.width; + var H = canvas.height; + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#050403'; + ctx.fillRect(0, 0, W, H); + + ctx.save(); + ctx.translate(panX, panY); + ctx.scale(zoom, zoom); + + // Grid dots + ctx.fillStyle = 'rgba(255,90,0,0.05)'; + var gs = 50; + for (var gx = 0; gx <= GRAPH_W + gs; gx += gs) { + for (var gy = 0; gy <= GRAPH_H + gs; gy += gs) { + ctx.beginPath(); + ctx.arc(gx, gy, 1.2, 0, Math.PI * 2); + ctx.fill(); + } + } + + // Edges + EDGES.forEach(function (e) { + var fn = nodeMap[e.from]; + var tn = nodeMap[e.to]; + if (!fn || !tn) return; + var fv = visible(fn); + var tv = visible(tn); + var both = fv && tv; + var neither = !fv && !tv; + var alpha = neither ? 0.08 : both ? 0.55 : 0.22; + + var ep = edgeEndpoints(fn, tn); + var sx = ep.sx, sy = ep.sy, ex = ep.ex, ey = ep.ey; + + var angle = Math.atan2(ey - sy, ex - sx); + var hl = 9; + + ctx.globalAlpha = alpha; + ctx.strokeStyle = '#c74700'; + ctx.fillStyle = '#c74700'; + ctx.lineWidth = 1.4; + + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(ex, ey); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - hl * Math.cos(angle - Math.PI / 6), ey - hl * Math.sin(angle - Math.PI / 6)); + ctx.lineTo(ex - hl * Math.cos(angle + Math.PI / 6), ey - hl * Math.sin(angle + Math.PI / 6)); + ctx.closePath(); + ctx.fill(); + + // Label + var mx = (sx + ex) / 2; + var my = (sy + ey) / 2; + ctx.font = '9px "IBM Plex Mono",ui-monospace,monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + var tw = ctx.measureText(e.label).width; + ctx.fillStyle = 'rgba(5,4,3,0.9)'; + ctx.fillRect(mx - tw / 2 - 3, my - 7, tw + 6, 14); + ctx.fillStyle = neither ? '#612000' : '#a89c91'; + ctx.fillText(e.label, mx, my); + ctx.globalAlpha = 1; + }); + + // Nodes + NODES.forEach(function (n) { + var vis = visible(n); + var hov = hoverId === n.id; + var nx = n.x - NODE_W / 2; + var ny = n.y - NODE_H / 2; + var color = TECH_COLORS[n.tech] || '#a89c91'; + var r = 3; + + ctx.globalAlpha = vis ? 1 : 0.18; + + // Fill + ctx.fillStyle = hov ? 'rgba(255,90,0,0.16)' : 'rgba(11,11,10,0.95)'; + ctx.strokeStyle = hov ? '#ff5a00' : color; + ctx.lineWidth = hov ? 2 : 1.5; + roundRect(ctx, nx, ny, NODE_W, NODE_H, r); + ctx.fill(); + ctx.stroke(); + + // Left accent bar + ctx.fillStyle = color; + roundRect(ctx, nx, ny + 4, 3, NODE_H - 8, 1.5); + ctx.fill(); + + // Label + ctx.fillStyle = hov ? '#ff5a00' : (vis ? '#ece3d8' : '#4a3c30'); + ctx.font = '600 11.5px "Inter","IBM Plex Sans",system-ui,sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(n.label, nx + 11, ny + NODE_H / 2, NODE_W - 50); + + // Tech badge + var bx = nx + NODE_W - 37; + var by = ny + NODE_H / 2 - 7; + ctx.fillStyle = color + '20'; + ctx.strokeStyle = color + '50'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect(bx, by, 31, 14); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = color; + ctx.font = '700 8.5px "IBM Plex Mono",ui-monospace,monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(n.tech.toUpperCase().slice(0, 5), bx + 15.5, by + 7); + + ctx.globalAlpha = 1; + }); + + ctx.restore(); + renderMinimap(); + } + + function renderMinimap() { + var mc = minimap.getContext('2d'); + var mw = MINIMAP_W; + var mh = MINIMAP_H; + var pad = 14; + + mc.clearRect(0, 0, mw, mh); + mc.fillStyle = 'rgba(11,11,10,0.93)'; + mc.strokeStyle = '#2d211a'; + mc.lineWidth = 1; + mc.fillRect(0, 0, mw, mh); + mc.strokeRect(0, 0, mw, mh); + + var sx = (mw - pad) / GRAPH_W; + var sy = (mh - pad) / GRAPH_H; + var sc = Math.min(sx, sy); + var ox = pad / 2; + var oy = pad / 2; + + // Edges + mc.strokeStyle = 'rgba(199,71,0,0.35)'; + mc.lineWidth = 0.6; + EDGES.forEach(function (e) { + var fn = nodeMap[e.from]; + var tn = nodeMap[e.to]; + if (!fn || !tn) return; + mc.beginPath(); + mc.moveTo(fn.x * sc + ox, fn.y * sc + oy); + mc.lineTo(tn.x * sc + ox, tn.y * sc + oy); + mc.stroke(); + }); + + // Nodes + NODES.forEach(function (n) { + var color = TECH_COLORS[n.tech] || '#a89c91'; + mc.fillStyle = visible(n) ? color : 'rgba(45,33,26,0.55)'; + mc.beginPath(); + mc.arc(n.x * sc + ox, n.y * sc + oy, 3.2, 0, Math.PI * 2); + mc.fill(); + }); + + // Viewport rect + var vx = (-panX / zoom) * sc + ox; + var vy = (-panY / zoom) * sc + oy; + var vw = (canvas.width / zoom) * sc; + var vh = (canvas.height / zoom) * sc; + mc.strokeStyle = 'rgba(255,90,0,0.65)'; + mc.lineWidth = 1; + mc.strokeRect(vx, vy, vw, vh); + } + + // --- Fit --- + function fitToCanvas() { + var pad = 32; + zoom = Math.min((canvas.width - pad * 2) / GRAPH_W, (canvas.height - pad * 2) / GRAPH_H); + panX = pad; + panY = pad; + } + + function resizeCanvas() { + var w = canvasWrap.clientWidth || 800; + var h = canvasWrap.clientHeight || 520; + canvas.width = w; + canvas.height = h; + } + + // --- Zoom helpers --- + function zoomAt(cx, cy, factor) { + var oldZoom = zoom; + zoom = Math.min(Math.max(zoom * factor, 0.2), 5); + panX = cx - (cx - panX) * zoom / oldZoom; + panY = cy - (cy - panY) * zoom / oldZoom; + } + + // --- Event handlers --- + btnIn.addEventListener('click', function () { + zoomAt(canvas.width / 2, canvas.height / 2, 1.25); + render(); + }); + btnOut.addEventListener('click', function () { + zoomAt(canvas.width / 2, canvas.height / 2, 0.8); + render(); + }); + btnReset.addEventListener('click', function () { + fitToCanvas(); + render(); + }); + + canvas.addEventListener('wheel', function (e) { + e.preventDefault(); + var rect = canvas.getBoundingClientRect(); + zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY > 0 ? 0.9 : 1.1); + render(); + }, { passive: false }); + + canvas.addEventListener('mousedown', function (e) { + dragging = true; + dragOrigin = { x: e.clientX - panX, y: e.clientY - panY }; + canvas.style.cursor = 'grabbing'; + }); + + canvas.addEventListener('mousemove', function (e) { + if (dragging) { + panX = e.clientX - dragOrigin.x; + panY = e.clientY - dragOrigin.y; + render(); + } else { + var rect = canvas.getBoundingClientRect(); + var n = nodeAt(e.clientX - rect.left, e.clientY - rect.top); + var nid = n ? n.id : null; + if (nid !== hoverId) { + hoverId = nid; + canvas.style.cursor = hoverId ? 'pointer' : 'grab'; + render(); + } + } + }); + + canvas.addEventListener('mouseup', function () { + dragging = false; + canvas.style.cursor = hoverId ? 'pointer' : 'grab'; + }); + + canvas.addEventListener('mouseleave', function () { + dragging = false; + canvas.style.cursor = 'default'; + }); + + canvas.addEventListener('keydown', function (e) { + if (e.key === '+' || e.key === '=') { + zoomAt(canvas.width / 2, canvas.height / 2, 1.25); render(); + } else if (e.key === '-') { + zoomAt(canvas.width / 2, canvas.height / 2, 0.8); render(); + } else if (e.key === '0') { + fitToCanvas(); render(); + } + }); + + // Touch + var lastPinchDist = null; + canvas.addEventListener('touchstart', function (e) { + if (e.touches.length === 1) { + dragging = true; + dragOrigin = { x: e.touches[0].clientX - panX, y: e.touches[0].clientY - panY }; + } else if (e.touches.length === 2) { + var dx = e.touches[0].clientX - e.touches[1].clientX; + var dy = e.touches[0].clientY - e.touches[1].clientY; + lastPinchDist = Math.hypot(dx, dy); + } + }, { passive: true }); + + canvas.addEventListener('touchmove', function (e) { + e.preventDefault(); + if (e.touches.length === 1 && dragging) { + panX = e.touches[0].clientX - dragOrigin.x; + panY = e.touches[0].clientY - dragOrigin.y; + render(); + } else if (e.touches.length === 2) { + var dx = e.touches[0].clientX - e.touches[1].clientX; + var dy = e.touches[0].clientY - e.touches[1].clientY; + var dist = Math.hypot(dx, dy); + if (lastPinchDist) { + var rect = canvas.getBoundingClientRect(); + var cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left; + var cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top; + zoomAt(cx, cy, dist / lastPinchDist); + render(); + } + lastPinchDist = dist; + } + }, { passive: false }); + + canvas.addEventListener('touchend', function () { + dragging = false; + lastPinchDist = null; + }); + + var ro = new ResizeObserver(function () { + resizeCanvas(); + fitToCanvas(); + render(); + }); + ro.observe(canvasWrap); + + // Initial draw + resizeCanvas(); + fitToCanvas(); + render(); + + return { + refresh: render, + setFilter: function (tech) { activeFilter = tech; render(); }, + destroy: function () { ro.disconnect(); containerEl.removeChild(wrapper); }, + }; + } + + // --- Utility --- + function mkBtn(text, label) { + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = text; + b.setAttribute('aria-label', label); + return b; + } + + function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + } + + window.CodebaseIntelligenceGraph = { init: init }; +})(); diff --git a/templates/agent-work-app/codebase-intelligence-panels.js b/templates/agent-work-app/codebase-intelligence-panels.js new file mode 100644 index 0000000..9cc0088 --- /dev/null +++ b/templates/agent-work-app/codebase-intelligence-panels.js @@ -0,0 +1,869 @@ +// Inspector panel and Ask Cento panel module for the Codebase Intelligence route. +// Exposes window.CIpanels with render + init functions; no live model dependency. + +(function (global) { + "use strict"; + + // --- CSS injection --- + + function injectStyles() { + if (document.getElementById("ci-panels-styles")) return; + const style = document.createElement("style"); + style.id = "ci-panels-styles"; + style.textContent = ` +/* Inspector Panel */ +.ciInspectorPanel { + display: flex; + flex-direction: column; + background: var(--panel, #0b0b0a); + border-left: 1px solid var(--panel-line, #2d211a); + height: 100%; + overflow: hidden; + min-width: 0; + font-size: 0.82rem; + color: var(--text, #ece3d8); +} + +.ciInspectorHeader { + display: flex; + align-items: center; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; +} + +.ciInspectorTitle { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted, #a89c91); +} + +.ciInspectorMeta { + padding: 0.6rem 0.85rem; + border-bottom: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.ciMetaPath { + display: flex; + align-items: baseline; + gap: 0.4rem; + flex-wrap: wrap; + word-break: break-all; +} + +.ciMetaPath code { + font-family: "IBM Plex Mono", monospace; + font-size: 0.78rem; + color: var(--orange-soft, #ff8a2a); +} + +.ciMetaRow { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.ciMetaLabel { + color: var(--muted, #a89c91); + margin-right: 0.25rem; + font-size: 0.75rem; +} + +.ciLangBadge { + background: var(--panel-soft, #12110f); + border: 1px solid var(--line-dim, #612000); + color: var(--orange-soft, #ff8a2a); + padding: 0.1rem 0.45rem; + border-radius: 2px; + font-size: 0.72rem; + font-weight: 500; + margin-left: auto; +} + +.ciInspectorTabs { + display: flex; + border-bottom: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.ciInspectorTabs::-webkit-scrollbar { display: none; } + +.ciInspectorTab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--muted, #a89c91); + padding: 0.45rem 0.7rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + border-radius: 0; + flex-shrink: 0; +} + +.ciInspectorTab:hover { + color: var(--text, #ece3d8); + border-color: transparent; + background: transparent; +} + +.ciInspectorTab.active { + color: var(--orange, #ff5a00); + border-bottom-color: var(--orange, #ff5a00); +} + +.ciInspectorTabContent { + flex: 1; + overflow-y: auto; + padding: 0.75rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.ciTabPane.hidden { display: none; } + +.ciPurpose { + margin: 0; + color: var(--muted, #a89c91); + line-height: 1.55; + font-size: 0.8rem; +} + +.ciSection { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.ciSectionTitle { + margin: 0; + font-size: 0.73rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted, #a89c91); + padding-bottom: 0.25rem; + border-bottom: 1px solid var(--panel-line, #2d211a); +} + +.ciRouteList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.ciRouteRow { + display: flex; + align-items: baseline; + gap: 0.4rem; + flex-wrap: wrap; +} + +.ciMethodBadge { + font-family: "IBM Plex Mono", monospace; + font-size: 0.68rem; + font-weight: 600; + color: var(--green, #10b66a); + min-width: 2.2rem; +} + +.ciRoutePath { + font-family: "IBM Plex Mono", monospace; + font-size: 0.75rem; + color: var(--cyan, #4dd7d1); + word-break: break-all; +} + +.ciRouteLabel { + color: var(--muted, #a89c91); + font-size: 0.74rem; +} + +.ciDatastore { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} + +.ciDsBadge { + background: var(--panel-soft, #12110f); + border: 1px solid var(--panel-line, #2d211a); + color: var(--yellow, #ffb02e); + padding: 0.1rem 0.4rem; + border-radius: 2px; + font-size: 0.71rem; + font-weight: 600; +} + +.ciDsPath { + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + color: var(--muted, #a89c91); + word-break: break-all; +} + +.ciDebtList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.22rem; +} + +.ciDebtItem { + color: var(--muted, #a89c91); + font-size: 0.78rem; + padding-left: 0.9rem; + position: relative; +} + +.ciDebtItem::before { + content: "▸"; + position: absolute; + left: 0; + color: var(--line-dim, #612000); +} + +.ciAiAssistant { + border: 1px solid var(--panel-line, #2d211a); + border-radius: 3px; + padding: 0.65rem; + background: var(--panel-soft, #12110f); +} + +.ciAiPromptBox { + background: var(--bg, #050403); + border: 1px solid var(--line-dim, #612000); + border-radius: 2px; + padding: 0.4rem 0.55rem; + font-size: 0.78rem; + color: var(--text, #ece3d8); + margin-bottom: 0.5rem; +} + +.ciAiAnswer { + font-size: 0.76rem; + color: var(--muted, #a89c91); + line-height: 1.5; + white-space: pre-line; + margin-bottom: 0.45rem; +} + +.ciAiRefs { + list-style: none; + margin: 0 0 0.5rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.ciAiRefs li { + font-family: "IBM Plex Mono", monospace; + font-size: 0.71rem; + color: var(--cyan, #4dd7d1); +} + +.ciAiReportLink { + display: inline-block; + font-size: 0.75rem; + color: var(--orange, #ff5a00); + text-decoration: none; + border: 1px solid var(--line-dim, #612000); + padding: 0.2rem 0.55rem; + border-radius: 2px; + margin-top: 0.25rem; +} + +.ciAiReportLink:hover { + border-color: var(--orange, #ff5a00); + background: rgba(255,90,0,0.08); +} + +.ciPlaceholder { + margin: 0; + color: var(--muted, #a89c91); + font-size: 0.78rem; + font-style: italic; +} + +/* Ask Cento Panel */ +.ciAskPanel { + display: flex; + flex-direction: column; + background: var(--panel, #0b0b0a); + border-top: 1px solid var(--panel-line, #2d211a); + font-size: 0.82rem; + color: var(--text, #ece3d8); + min-width: 0; +} + +.ciAskHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.85rem; + border-bottom: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; + flex-wrap: wrap; + gap: 0.4rem; +} + +.ciAskTitle { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted, #a89c91); +} + +.ciAskContext { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.ciAskContextLabel { + font-size: 0.72rem; + color: var(--muted, #a89c91); +} + +.ciAskContextValue { + background: var(--panel-soft, #12110f); + border: 1px solid var(--panel-line, #2d211a); + padding: 0.1rem 0.45rem; + border-radius: 2px; + font-size: 0.72rem; + color: var(--text, #ece3d8); +} + +.ciAskThread { + flex: 1; + overflow-y: auto; + padding: 0.65rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + max-height: 12rem; +} + +.ciAskBubble { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.ciAskAvatar { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.ciAskAvatarUser { color: var(--muted, #a89c91); } +.ciAskAvatarCento { color: var(--orange, #ff5a00); } + +.ciAskMessage { + margin: 0; + background: var(--panel-soft, #12110f); + border: 1px solid var(--panel-line, #2d211a); + border-radius: 2px; + padding: 0.4rem 0.6rem; + color: var(--text, #ece3d8); + font-size: 0.8rem; + line-height: 1.45; +} + +.ciAskAnswerWrap { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.ciAskAnswer { + margin: 0; + flex: 1; + min-width: 0; + color: var(--muted, #a89c91); + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-line; + word-break: break-word; +} + +.ciAskRefBlock { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 9rem; + flex-shrink: 0; +} + +.ciAskRefTitle { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, #a89c91); +} + +.ciAskRefList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.ciAskRef { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.4rem; +} + +.ciAskRefLink { + font-family: "IBM Plex Mono", monospace; + font-size: 0.71rem; + color: var(--cyan, #4dd7d1); + text-decoration: none; + word-break: break-all; +} + +.ciAskRefLink:hover { text-decoration: underline; } + +.ciAskRefLines { + font-family: "IBM Plex Mono", monospace; + font-size: 0.7rem; + color: var(--muted, #a89c91); + white-space: nowrap; +} + +.ciAskRefExtra { + font-size: 0.72rem; + color: var(--muted, #a89c91); + font-style: italic; +} + +.ciAskActions { + display: flex; + align-items: center; + padding: 0.3rem 0.85rem; + gap: 0.75rem; + border-top: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; +} + +.ciAskFollowUpLink { + font-size: 0.75rem; + color: var(--orange-soft, #ff8a2a); + text-decoration: none; +} + +.ciAskFollowUpLink:hover { text-decoration: underline; } + +.ciAskInputRow { + display: flex; + align-items: center; + padding: 0.5rem 0.85rem; + gap: 0.5rem; + border-top: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; +} + +.ciAskInput { + flex: 1; + min-width: 0; + background: var(--panel-soft, #12110f); + border: 1px solid var(--panel-line, #2d211a); + color: var(--text, #ece3d8); + padding: 0.4rem 0.6rem; + border-radius: 2px; + font-size: 0.8rem; +} + +.ciAskInput:focus { + outline: none; + border-color: var(--line-dim, #612000); +} + +.ciAskInput::placeholder { color: var(--muted, #a89c91); } + +.ciAskSend { + background: var(--line-dim, #612000); + border: 1px solid var(--line-dim, #612000); + color: var(--text, #ece3d8); + width: 2rem; + height: 2rem; + padding: 0; + border-radius: 2px; + cursor: pointer; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ciAskSend:hover { + background: var(--orange, #ff5a00); + border-color: var(--orange, #ff5a00); +} + +@media (max-width: 768px) { + .ciAskAnswerWrap { flex-direction: column; } + .ciAskRefBlock { min-width: 0; } + .ciInspectorTabs { flex-wrap: wrap; } +} + `; + document.head.appendChild(style); + } + + // --- Fixture data --- + + const INSPECTOR_FIXTURE = { + path: "scripts/agent_work_app.py", + language: "Python", + size_kb: 31.4, + loc: 872, + modified: "Today, 10:42 AM", + purpose: + "ThrottlingHTTPServer local web app for Agent Work (Taskstream). Serves REST API and static UI. Processes issues, review, factory, artifacts, and health endpoints.", + api_routes: [ + { method: "GET", path: "/api/issues", label: "List issues" }, + { method: "GET", path: "/api/review", label: "Issue detail review" }, + { method: "GET", path: "/api/factory", label: "List factory runs" }, + { method: "GET", path: "/api/artifacts", label: "List catalog entries" }, + { method: "GET", path: "/api/health", label: "Health check" }, + ], + datastore: { + type: "SQLite", + path: "~/...state/cento/agent-work-app.sqlite3", + }, + tech_debt: [ + "Large single-file backend (872 LOC)", + "Missing DOM frontend (no framework)", + "Limited symbol search / no pagination", + "Workspace path needs pruning without pruning", + ], + ai_assistant: { + prompt: "Explain AgentWorkAppError and every route that can raise it", + answer: + "Here are all routes that can raise AgentWorkAppError, with any and\nwhere in the code.\n\nGET /api/issues\n- Raises when invalid query params or data load fails.\n\nPOST /api/review\n- Raised on invalid payload or persistence failure,\nscripts/agent_work_app.py:401-527\n\nGET /api/factory\n- Raises when accessing missing or unavailable\nfactory, scripts/agent_work_app.py:512-640\n\nGET /api/artifacts\n- Raises missing catalog entries.\n\nFull details with code snippets", + references: [ + { path: "scripts/agent_work_app.py", lines: 66 }, + { path: "scripts/agent_work_app.py", lines: 412 }, + { path: "scripts/agent_work_app.py", lines: 458 }, + { path: "scripts/agent_work_app.py", lines: 812 }, + ], + }, + }; + + const ASK_PANEL_FIXTURE = { + context: "Current Repository", + example_prompt: "Explain AgentWorkAppError and every route that can raise it", + answer: + "AgentWorkAppError is the base exception for the Agent Work web app. It's raised for domain and request errors and returned as JSON {\"error\": \"...\"} with appropriate HTTP status codes.\n\nAll routes that can raise it, with conditions and code references.", + references: [ + { path: "scripts/agent_work_app.py", lines: 412 }, + { path: "scripts/agent_work_app.py", lines: 458 }, + { path: "scripts/agent_work_app.py", lines: 612 }, + { path: "scripts/agent_work_app.py", lines: 736 }, + ], + extra_refs: 1, + }; + + // --- Helpers --- + + function esc(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + // --- Inspector Panel --- + + function renderInspectorPanel(data) { + data = data || INSPECTOR_FIXTURE; + + const tabs = ["Summary", "Call Graph", "Owners", "Debt", "Tests", "Evidence"]; + const tabsHtml = tabs + .map( + (t, i) => + `` + ) + .join(""); + + const routesHtml = (data.api_routes || []) + .map( + (r) => + `
  • ${esc(r.method)}${esc(r.path)}${esc(r.label)}
  • ` + ) + .join(""); + + const debtHtml = (data.tech_debt || []) + .map((d) => `
  • ${esc(d)}
  • `) + .join(""); + + const aiRefs = (data.ai_assistant.references || []) + .map((r) => `
  • ${esc(r.path)}:${esc(String(r.lines))}
  • `) + .join(""); + + return ` +
    +
    + Inspector +
    +
    +
    + File + ${esc(data.path)} +
    +
    + Size${esc(String(data.size_kb))} KB + Loc${esc(String(data.loc))} +
    +
    + Modified${esc(data.modified)} + ${esc(data.language)} +
    +
    + +
    +
    +

    ${esc(data.purpose)}

    + +
    +

    API Routes / Routes

    +
      ${routesHtml}
    +
    + +
    +

    Datastore

    +
    + ${esc(data.datastore.type)} + ${esc(data.datastore.path)} +
    +
    + +
    +

    Tech / Debt

    +
      ${debtHtml}
    +
    + +
    +

    AI Assistant (Cento)

    +
    ${esc(data.ai_assistant.prompt)}
    +
    ${esc(data.ai_assistant.answer)}
    +
      ${aiRefs}
    + Open in AI Report +
    +
    + + + + + + +
    +
    `.trim(); + } + + function initInspectorPanel(containerEl) { + if (!containerEl) return; + const tabs = containerEl.querySelectorAll(".ciInspectorTab"); + const panes = containerEl.querySelectorAll(".ciTabPane"); + tabs.forEach((tab) => { + tab.addEventListener("click", () => { + const target = tab.dataset.tab; + tabs.forEach((t) => { + t.classList.remove("active"); + t.setAttribute("aria-selected", "false"); + }); + tab.classList.add("active"); + tab.setAttribute("aria-selected", "true"); + panes.forEach((pane) => { + pane.classList.toggle("hidden", pane.dataset.pane !== target); + }); + }); + }); + } + + // --- Ask Cento Panel --- + + function renderAskCentoPanel(data) { + data = data || ASK_PANEL_FIXTURE; + + const refsHtml = (data.references || []) + .map( + (r) => + `
  • ${esc(r.path)}${esc(String(r.lines))}
  • ` + ) + .join(""); + const extraRef = + data.extra_refs > 0 + ? `
  • +${data.extra_refs} more reference${data.extra_refs > 1 ? "s" : ""}
  • ` + : ""; + + return ` +
    +
    + Ask Cento about code +
    + Context + ${esc(data.context)} +
    +
    +
    +
    + You +

    ${esc(data.example_prompt)}

    +
    +
    + Cento AI +
    +

    ${esc(data.answer)}

    +
    + Referenced in +
      ${refsHtml}${extraRef}
    +
    +
    +
    +
    + +
    + + +
    +
    `.trim(); + } + + function initAskCentoPanel(containerEl) { + if (!containerEl) return; + const input = containerEl.querySelector(".ciAskInput"); + const send = containerEl.querySelector(".ciAskSend"); + const thread = containerEl.querySelector(".ciAskThread"); + if (!input || !send) return; + + function appendUserBubble(text) { + if (!thread) return; + const div = document.createElement("div"); + div.className = "ciAskBubble ciAskBubbleUser"; + const avatar = document.createElement("span"); + avatar.className = "ciAskAvatar ciAskAvatarUser"; + avatar.textContent = "You"; + const msg = document.createElement("p"); + msg.className = "ciAskMessage"; + msg.textContent = text; + div.appendChild(avatar); + div.appendChild(msg); + thread.appendChild(div); + thread.scrollTop = thread.scrollHeight; + } + + function submitAsk() { + const val = input.value.trim(); + if (!val) return; + input.value = ""; + appendUserBubble(val); + } + + send.addEventListener("click", submitAsk); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submitAsk(); + } + }); + + const followUp = containerEl.querySelector(".ciAskFollowUpLink"); + if (followUp) { + followUp.addEventListener("click", (e) => { + e.preventDefault(); + input.focus(); + }); + } + } + + // --- API loader --- + + async function loadInspectorData(filePath) { + try { + const resp = await fetch( + `/api/codebase-intelligence/inspect?path=${encodeURIComponent(filePath)}` + ); + if (!resp.ok) return null; + return await resp.json(); + } catch { + return null; + } + } + + // --- Mount helpers --- + + function mountInspectorPanel(containerEl, data) { + if (!containerEl) return; + injectStyles(); + containerEl.innerHTML = renderInspectorPanel(data || INSPECTOR_FIXTURE); + initInspectorPanel(containerEl); + } + + function mountAskCentoPanel(containerEl, data) { + if (!containerEl) return; + injectStyles(); + containerEl.innerHTML = renderAskCentoPanel(data || ASK_PANEL_FIXTURE); + initAskCentoPanel(containerEl); + } + + // --- Public API --- + + global.CIpanels = { + INSPECTOR_FIXTURE, + ASK_PANEL_FIXTURE, + injectStyles, + renderInspectorPanel, + initInspectorPanel, + renderAskCentoPanel, + initAskCentoPanel, + loadInspectorData, + mountInspectorPanel, + mountAskCentoPanel, + }; +})(window); diff --git a/templates/agent-work-app/codebase-intelligence.css b/templates/agent-work-app/codebase-intelligence.css new file mode 100644 index 0000000..f95b4d2 --- /dev/null +++ b/templates/agent-work-app/codebase-intelligence.css @@ -0,0 +1,1273 @@ +/* Route-scoped styles for /codebase-intelligence + Scope: body.codebaseMode and #codebaseIntelligenceView + Do not use selectors from this file that would match the global shell. +*/ + +/* ─── Layout overrides ─────────────────────────────────────── */ + +body.codebaseMode .appShell { + grid-template-columns: 1fr; +} + +body.codebaseMode .sidebar, +body.codebaseMode .taskstreamNav { + display: none; +} + +body.codebaseMode .content { + padding: 0; + border: 0; + min-height: calc(100vh - 94px); + overflow: hidden; +} + +/* ─── Root view container ───────────────────────────────────── */ + +#codebaseIntelligenceView { + display: grid; + grid-template-columns: 170px minmax(0, 1fr) 360px; + grid-template-rows: auto minmax(0, 1fr); + min-height: calc(100vh - 94px); + background: #0b0b0a; + color: var(--text); + font-size: 0.82rem; +} + +#codebaseIntelligenceView.hidden { + display: none; +} + +#codebaseIntelligenceView:not(.hidden) { + display: grid; +} + +#codebaseIntelligenceView .sdHubChildHeader { + grid-column: 1 / -1; + grid-row: 1; +} + +/* ─── Left sidebar ──────────────────────────────────────────── */ + +.ciSidebar { + grid-column: 1; + grid-row: 2; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + background: linear-gradient(180deg, #0d0d0c, #090908); + overflow-y: auto; + padding-bottom: 1rem; +} + +.ciSidebarSection { + padding: 0.9rem 0.85rem 0.65rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.6); +} + +.ciSidebarSection:last-child { + border-bottom: 0; +} + +.ciSidebarLabel { + display: block; + margin: 0 0 0.45rem; + color: var(--muted); + font-size: 0.67rem; + font-weight: 900; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.ciSidebarNav { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.05rem; +} + +.ciSidebarNav a { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.32rem 0.45rem; + border-radius: 2px; + color: var(--muted); + text-decoration: none; + font-size: 0.8rem; + transition: color 0.12s, background 0.12s; + white-space: nowrap; +} + +.ciSidebarNav a .ciNavIcon { + width: 14px; + text-align: center; + opacity: 0.7; + flex-shrink: 0; +} + +.ciSidebarNav a:hover { + color: var(--text); + background: rgba(255, 90, 0, 0.06); +} + +.ciSidebarNav a.active { + color: var(--orange); + background: rgba(255, 90, 0, 0.1); + font-weight: 600; +} + +.ciSidebarNav a.active .ciNavIcon { + opacity: 1; +} + +/* Repository section */ + +.ciRepo { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.9rem 0.85rem 0.65rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.6); +} + +.ciRepoBranch { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.79rem; + color: var(--text); +} + +.ciRepoBranch .ciBranchIcon { + opacity: 0.55; + font-size: 0.75rem; +} + +.ciRepoStatus { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.38rem; + border: 1px solid var(--orange); + border-radius: 2px; + color: var(--orange); + font-size: 0.67rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + margin-left: auto; +} + +.ciRepoStatus.clean { + border-color: var(--green); + color: var(--green); +} + +.ciRepoMeta { + display: flex; + flex-direction: column; + gap: 0.18rem; + color: var(--muted); + font-size: 0.73rem; +} + +.ciRepoDirty { + display: flex; + align-items: flex-start; + gap: 0.38rem; + color: var(--yellow); + font-size: 0.75rem; + margin-top: 0.1rem; +} + +.ciRepoDirtyIcon { + flex-shrink: 0; + margin-top: 0.05rem; +} + +/* Code health */ + +.ciHealthList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.42rem; + padding: 0.9rem 0.85rem 0.65rem; +} + +.ciHealthRow { + display: grid; + grid-template-columns: 54px 1fr 30px; + align-items: center; + gap: 0.45rem; +} + +.ciHealthName { + font-size: 0.75rem; + color: var(--muted); + white-space: nowrap; +} + +.ciHealthBar { + height: 4px; + background: rgba(45, 33, 26, 0.7); + border-radius: 2px; + overflow: hidden; +} + +.ciHealthFill { + height: 100%; + border-radius: 2px; + background: var(--green); + transition: width 0.3s ease; +} + +.ciHealthFill.lang-python { background: #3b82f6; } +.ciHealthFill.lang-shell { background: var(--green); } +.ciHealthFill.lang-go { background: var(--yellow); } +.ciHealthFill.lang-js { background: #f59e0b; } +.ciHealthFill.lang-ts { background: var(--cyan); } + +.ciHealthPct { + font-size: 0.73rem; + color: var(--text); + text-align: right; +} + +.ciHealthReport { + display: inline-flex; + align-items: center; + gap: 0.3rem; + margin: 0.35rem 0.85rem 0; + padding: 0 0; + color: var(--orange); + font-size: 0.74rem; + text-decoration: none; +} + +.ciHealthReport:hover { + text-decoration: underline; +} + +/* ─── Main content area ─────────────────────────────────────── */ + +.ciMain { + grid-column: 2; + grid-row: 2; + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + border-right: 1px solid var(--line); +} + +/* ─── Capability cards bar ───────────────────────────────────── */ + +.ciCapabilityBar { + display: flex; + gap: 0; + border-bottom: 1px solid var(--line); + flex-shrink: 0; +} + +.ciCapCard { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-right: 1px solid rgba(45, 33, 26, 0.7); + flex: 1; + min-width: 0; + cursor: default; + transition: background 0.1s; +} + +.ciCapCard:last-child { + border-right: 0; +} + +.ciCapCard:hover { + background: rgba(255, 90, 0, 0.04); +} + +.ciCapIcon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 3px; + flex-shrink: 0; + font-size: 0.9rem; +} + +.ciCapIcon.blue { background: rgba(59, 130, 246, 0.2); color: #3b82f6; } +.ciCapIcon.purple { background: rgba(139, 92, 246, 0.2); color: #8b5cf6; } +.ciCapIcon.teal { background: rgba(77, 215, 209, 0.2); color: var(--cyan); } +.ciCapIcon.yellow { background: rgba(255, 176, 46, 0.2); color: var(--yellow); } +.ciCapIcon.green { background: rgba(16, 182, 106, 0.2); color: var(--green); } +.ciCapIcon.orange { background: rgba(255, 90, 0, 0.2); color: var(--orange); } + +.ciCapBody { + display: flex; + flex-direction: column; + min-width: 0; +} + +.ciCapCount { + font-size: 0.83rem; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ciCapDetail { + font-size: 0.68rem; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ─── Graph toolbar ──────────────────────────────────────────── */ + +.ciGraphToolbar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.85rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.6); + background: rgba(11, 11, 10, 0.96); + flex-shrink: 0; +} + +.ciGraphTitle { + font-size: 0.82rem; + font-weight: 700; + color: var(--text); + white-space: nowrap; +} + +.ciGraphTitle small { + font-weight: 400; + color: var(--orange); + font-size: 0.72rem; + margin-left: 0.35rem; + text-decoration: underline; + cursor: pointer; +} + +.ciGraphFilters { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; + margin-right: auto; +} + +.ciFilterLabel { + font-size: 0.72rem; + color: var(--muted); + margin-right: 0.1rem; +} + +.ciFilterBtn { + padding: 0.15rem 0.55rem; + border: 1px solid rgba(45, 33, 26, 0.8); + border-radius: 2px; + background: transparent; + color: var(--muted); + font-size: 0.71rem; + cursor: pointer; + transition: border-color 0.1s, color 0.1s, background 0.1s; +} + +.ciFilterBtn:hover { + border-color: var(--line-dim); + color: var(--text); +} + +.ciFilterBtn.active { + border-color: var(--orange); + color: var(--orange); + background: rgba(255, 90, 0, 0.08); +} + +.ciFilterBtn.clear { + border-color: transparent; + color: var(--muted); +} + +.ciFilterBtn.clear:hover { + color: var(--text); +} + +.ciGraphControls { + display: flex; + align-items: center; + gap: 0.35rem; + margin-left: auto; + flex-shrink: 0; +} + +.ciSelect { + padding: 0.22rem 0.55rem; + border: 1px solid var(--line-dim); + border-radius: 2px; + background: #10100f; + color: var(--text); + font-size: 0.72rem; + cursor: pointer; + appearance: none; + padding-right: 1.4rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23a89c91'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.45rem center; +} + +.ciSelect:focus { + outline: 1px solid var(--orange); +} + +.ciOptionsBtn { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.22rem 0.6rem; + border: 1px solid var(--line-dim); + border-radius: 2px; + background: #10100f; + color: var(--muted); + font-size: 0.72rem; + cursor: pointer; +} + +.ciOptionsBtn:hover { + border-color: var(--orange); + color: var(--orange); +} + +/* ─── Dependency graph canvas ────────────────────────────────── */ + +.ciGraphCanvas { + flex: 1; + position: relative; + background: + radial-gradient(circle at 50% 50%, rgba(255, 90, 0, 0.03), transparent 60%), + #0d0d0c; + overflow: hidden; + min-height: 320px; +} + +.ciLoading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + color: var(--muted); + font-size: 0.78rem; +} + +.ciGraphSvg { + width: 100%; + height: 100%; +} + +/* Dependency graph nodes */ + +.ciNode { + position: absolute; + border: 1px solid; + border-radius: 3px; + padding: 0.5rem 0.65rem; + min-width: 120px; + max-width: 180px; + cursor: pointer; + transition: box-shadow 0.15s, border-color 0.15s; + background: rgba(13, 13, 12, 0.92); + user-select: none; +} + +.ciNode:hover { + box-shadow: 0 0 0 2px currentColor; +} + +.ciNode.active { + box-shadow: 0 0 0 2px currentColor, 0 0 12px rgba(255, 90, 0, 0.25); +} + +.ciNode.blue { border-color: #3b82f6; color: #3b82f6; } +.ciNode.purple { border-color: #8b5cf6; color: #8b5cf6; } +.ciNode.yellow { border-color: var(--yellow); color: var(--yellow); } +.ciNode.teal { border-color: var(--cyan); color: var(--cyan); } +.ciNode.green { border-color: var(--green); color: var(--green); } +.ciNode.orange { border-color: var(--orange); color: var(--orange); } +.ciNode.muted { border-color: var(--line-dim); color: var(--muted); } + +.ciNodeHeader { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.25rem; +} + +.ciNodeDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.ciNodeTitle { + font-size: 0.74rem; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ciNodeSub { + font-size: 0.67rem; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ciNodeMeta { + display: flex; + gap: 0.45rem; + margin-top: 0.3rem; +} + +.ciNodeBadge { + font-size: 0.63rem; + padding: 0.08rem 0.35rem; + border: 1px solid rgba(168, 156, 145, 0.2); + border-radius: 2px; + color: var(--muted); +} + +/* Graph edges / SVG lines */ + +.ciEdge { + stroke: rgba(45, 33, 26, 0.8); + stroke-width: 1.5; + fill: none; +} + +.ciEdge.active { + stroke: var(--orange-soft); + stroke-width: 2; +} + +/* Graph minimap */ + +.ciMinimap { + position: absolute; + bottom: 0.75rem; + right: 0.75rem; + width: 120px; + height: 70px; + border: 1px solid var(--line-dim); + background: rgba(11, 11, 10, 0.88); + border-radius: 2px; + overflow: hidden; +} + +.ciMinimapViewport { + position: absolute; + border: 1px solid var(--orange); + background: rgba(255, 90, 0, 0.08); + border-radius: 1px; +} + +/* Zoom controls */ + +.ciZoomControls { + position: absolute; + bottom: 0.75rem; + left: 0.75rem; + display: flex; + gap: 0.25rem; +} + +.ciZoomBtn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line-dim); + background: rgba(11, 11, 10, 0.9); + color: var(--muted); + font-size: 0.9rem; + border-radius: 2px; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.ciZoomBtn:hover { + border-color: var(--orange); + color: var(--orange); +} + +/* ─── Codebase Data Flow strip ───────────────────────────────── */ + +.ciDataFlow { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: rgba(11, 11, 10, 0.96); + padding: 0.65rem 0.85rem; + flex-shrink: 0; +} + +.ciDataFlowTitle { + font-size: 0.76rem; + font-weight: 700; + color: var(--text); + margin: 0 0 0.5rem; +} + +.ciDataFlowTitle small { + font-weight: 400; + color: var(--muted); + font-size: 0.7rem; + margin-left: 0.4rem; +} + +.ciDataFlowNodes { + display: flex; + align-items: center; + gap: 0; + overflow-x: auto; +} + +.ciDfNode { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.7rem; + border: 1px solid rgba(45, 33, 26, 0.8); + border-radius: 3px; + background: rgba(16, 16, 15, 0.9); + min-width: 110px; + flex-shrink: 0; + cursor: default; + transition: border-color 0.1s; +} + +.ciDfNode:hover { + border-color: var(--line); +} + +.ciDfArrow { + color: var(--muted); + font-size: 0.85rem; + flex-shrink: 0; + padding: 0 0.2rem; +} + +.ciDfIcon { + font-size: 1rem; + flex-shrink: 0; +} + +.ciDfBody { + display: flex; + flex-direction: column; +} + +.ciDfName { + font-size: 0.73rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.ciDfSub { + font-size: 0.66rem; + color: var(--muted); + white-space: nowrap; +} + +.ciDfCount { + font-size: 0.64rem; + color: var(--muted); + white-space: nowrap; +} + +/* ─── AI Ask panel ───────────────────────────────────────────── */ + +.ciAskPanel { + border-top: 1px solid rgba(45, 33, 26, 0.6); + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.ciAskHeader { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.5); +} + +.ciAskTitle { + font-size: 0.79rem; + font-weight: 700; + color: var(--text); +} + +.ciAskScope { + display: flex; + align-items: center; + gap: 0.3rem; + margin-left: auto; + color: var(--muted); + font-size: 0.71rem; +} + +.ciAskScopeBtn { + padding: 0.12rem 0.45rem; + border: 1px solid var(--line-dim); + border-radius: 2px; + background: transparent; + color: var(--muted); + font-size: 0.7rem; + cursor: pointer; +} + +.ciAskScopeBtn:hover, +.ciAskScopeBtn.active { + border-color: var(--orange); + color: var(--orange); +} + +.ciAskThread { + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + flex: 1; + max-height: 220px; +} + +.ciAskMsg { + padding: 0.55rem 0.85rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.35); + display: flex; + gap: 0.6rem; +} + +.ciAskMsg.user { background: rgba(255, 90, 0, 0.03); } +.ciAskMsg.assistant { background: rgba(11, 11, 10, 0.9); } + +.ciAskAvatar { + font-size: 0.72rem; + font-weight: 700; + color: var(--muted); + white-space: nowrap; + padding-top: 0.05rem; + min-width: 36px; +} + +.ciAskMsg.user .ciAskAvatar { color: var(--orange); } + +.ciAskBody { + flex: 1; + min-width: 0; +} + +.ciAskText { + font-size: 0.77rem; + color: var(--text); + line-height: 1.55; +} + +.ciAskWarning { + display: flex; + align-items: flex-start; + gap: 0.4rem; + margin-top: 0.4rem; + padding: 0.4rem 0.55rem; + border: 1px solid rgba(255, 176, 46, 0.3); + border-radius: 2px; + background: rgba(255, 176, 46, 0.06); + font-size: 0.72rem; + color: var(--yellow); +} + +.ciAskWarningIcon { flex-shrink: 0; margin-top: 0.05rem; } + +.ciAskRefs { + display: flex; + flex-direction: column; + gap: 0.18rem; + margin-top: 0.45rem; +} + +.ciAskRef { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.69rem; + color: var(--orange); + text-decoration: none; +} + +.ciAskRef:hover { text-decoration: underline; } + +.ciAskRefLine { + color: var(--muted); + font-size: 0.67rem; +} + +.ciAskInput { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.85rem; + border-top: 1px solid rgba(45, 33, 26, 0.5); +} + +.ciAskInputField { + flex: 1; + background: rgba(18, 17, 15, 0.9); + border: 1px solid var(--line-dim); + border-radius: 2px; + color: var(--text); + font-size: 0.78rem; + padding: 0.38rem 0.6rem; +} + +.ciAskInputField::placeholder { + color: rgba(168, 156, 145, 0.45); +} + +.ciAskInputField:focus { + outline: 1px solid var(--orange); + border-color: var(--orange); +} + +.ciAskSend { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.38rem 0.7rem; + border: 1px solid var(--line-dim); + border-radius: 2px; + background: #10100f; + color: var(--muted); + font-size: 0.74rem; + cursor: pointer; + white-space: nowrap; +} + +.ciAskSend:hover { + border-color: var(--orange); + color: var(--orange); +} + +/* ─── Inspector panel ────────────────────────────────────────── */ + +.ciInspector { + grid-column: 3; + grid-row: 2; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, #0d0d0c, #0a0a09); + border-left: 0; + overflow-y: auto; +} + +.ciInspectorHeader { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--line); + flex-shrink: 0; +} + +.ciInspectorTitle { + font-size: 0.82rem; + font-weight: 700; + color: var(--text); +} + +.ciInspectorLang { + margin-left: auto; + padding: 0.1rem 0.45rem; + border: 1px solid rgba(59, 130, 246, 0.5); + border-radius: 2px; + color: #3b82f6; + font-size: 0.68rem; + font-weight: 600; +} + +.ciInspectorFile { + padding: 0.55rem 0.9rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.5); +} + +.ciInspectorPath { + font-size: 0.74rem; + color: var(--orange); + word-break: break-all; +} + +.ciInspectorMeta { + display: flex; + gap: 0.9rem; + margin-top: 0.35rem; +} + +.ciInspectorMetaItem { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.ciInspectorMetaLabel { + font-size: 0.63rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ciInspectorMetaValue { + font-size: 0.76rem; + color: var(--text); + font-weight: 600; +} + +/* Inspector tabs */ + +.ciInspectorTabs { + display: flex; + border-bottom: 1px solid var(--line); + flex-shrink: 0; + overflow-x: auto; +} + +.ciTabBtn { + padding: 0.45rem 0.75rem; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + font-size: 0.73rem; + cursor: pointer; + white-space: nowrap; + transition: color 0.12s, border-color 0.12s; +} + +.ciTabBtn:hover { color: var(--text); } + +.ciTabBtn.active { + color: var(--orange); + border-bottom-color: var(--orange); + font-weight: 600; +} + +/* Inspector content sections */ + +.ciInspectorContent { + flex: 1; + overflow-y: auto; + padding: 0.75rem 0.9rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.ciSection { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.ciSectionTitle { + font-size: 0.71rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); + margin: 0; +} + +.ciSectionBody { + font-size: 0.77rem; + color: var(--text); + line-height: 1.55; +} + +/* API routes list */ + +.ciRouteList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.22rem; +} + +.ciRouteRow { + display: flex; + align-items: baseline; + gap: 0.5rem; + font-size: 0.74rem; + padding: 0.2rem 0; +} + +.ciRouteMethod { + font-family: "IBM Plex Mono", monospace; + font-size: 0.67rem; + font-weight: 700; + padding: 0.08rem 0.38rem; + border-radius: 2px; + min-width: 36px; + text-align: center; +} + +.ciRouteMethod.get { background: rgba(16, 182, 106, 0.15); color: var(--green); } +.ciRouteMethod.post { background: rgba(59, 130, 246, 0.15); color: #3b82f6; } +.ciRouteMethod.put { background: rgba(255, 176, 46, 0.15); color: var(--yellow); } +.ciRouteMethod.delete { background: rgba(255, 90, 0, 0.15); color: var(--orange); } +.ciRouteMethod.patch { background: rgba(77, 215, 209, 0.15); color: var(--cyan); } + +.ciRoutePath { + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + color: var(--text); +} + +.ciRouteDesc { + font-size: 0.69rem; + color: var(--muted); + margin-left: auto; +} + +/* Tech debt / risk items */ + +.ciRiskList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.ciRiskItem { + display: flex; + align-items: flex-start; + gap: 0.45rem; + font-size: 0.74rem; + color: var(--text); +} + +.ciRiskBullet { + flex-shrink: 0; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--line-dim); + margin-top: 0.38rem; +} + +.ciRiskBullet.high { background: var(--orange); } +.ciRiskBullet.medium { background: var(--yellow); } +.ciRiskBullet.low { background: var(--green); } + +/* AI assistant card in inspector */ + +.ciAiCard { + border: 1px solid rgba(45, 33, 26, 0.6); + border-radius: 3px; + overflow: hidden; +} + +.ciAiCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.7rem; + background: rgba(18, 17, 15, 0.8); + border-bottom: 1px solid rgba(45, 33, 26, 0.5); +} + +.ciAiCardTitle { + font-size: 0.74rem; + font-weight: 700; + color: var(--text); +} + +.ciAiCardPill { + font-size: 0.65rem; + padding: 0.08rem 0.4rem; + background: rgba(255, 90, 0, 0.1); + border: 1px solid rgba(255, 90, 0, 0.3); + border-radius: 10px; + color: var(--orange); +} + +.ciAiCardBody { + padding: 0.6rem 0.7rem; + font-size: 0.75rem; + color: var(--text); + line-height: 1.6; +} + +.ciAiCardBody code { + font-family: "IBM Plex Mono", monospace; + font-size: 0.7rem; + color: var(--orange-soft); + background: rgba(255, 90, 0, 0.07); + padding: 0.05rem 0.25rem; + border-radius: 2px; +} + +.ciAiCardFooter { + display: flex; + justify-content: flex-end; + padding: 0.4rem 0.7rem; + border-top: 1px solid rgba(45, 33, 26, 0.4); +} + +.ciOpenAiBtn { + font-size: 0.71rem; + color: var(--orange); + text-decoration: none; + padding: 0.15rem 0; +} + +.ciOpenAiBtn:hover { text-decoration: underline; } + +/* ─── Status / badge utilities ───────────────────────────────── */ + +.ciBadge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.4rem; + border-radius: 2px; + font-size: 0.67rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ciBadge.healthy { background: rgba(16, 182, 106, 0.12); color: var(--green); border: 1px solid rgba(16, 182, 106, 0.3); } +.ciBadge.warn { background: rgba(255, 176, 46, 0.12); color: var(--yellow); border: 1px solid rgba(255, 176, 46, 0.3); } +.ciBadge.error { background: rgba(255, 90, 0, 0.12); color: var(--orange); border: 1px solid rgba(255, 90, 0, 0.3); } +.ciBadge.info { background: rgba(59, 130, 246, 0.12); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.3); } +.ciBadge.neutral { background: rgba(168, 156, 145, 0.1); color: var(--muted); border: 1px solid rgba(168, 156, 145, 0.2); } + +/* ─── Responsive — tablet (< 1100px) ────────────────────────── */ + +@media (max-width: 1100px) { + #codebaseIntelligenceView { + grid-template-columns: 160px minmax(0, 1fr) 300px; + } + + .ciCapCard { + padding: 0.5rem 0.55rem; + } + + .ciCapDetail { + display: none; + } + + .ciInspectorContent { + padding: 0.6rem 0.7rem; + } +} + +/* ─── Responsive — hide inspector (< 900px) ─────────────────── */ + +@media (max-width: 900px) { + #codebaseIntelligenceView { + grid-template-columns: 150px minmax(0, 1fr); + } + + .ciInspector { + display: none; + } + + .ciInspector.visible { + display: flex; + position: fixed; + top: 64px; + right: 0; + bottom: 0; + width: 320px; + z-index: 200; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.6); + border-left: 1px solid var(--line); + } +} + +/* ─── Responsive — collapse sidebar (< 680px) ───────────────── */ + +@media (max-width: 680px) { + #codebaseIntelligenceView { + grid-template-columns: 1fr; + } + + .ciMain, + .ciInspector { + grid-column: 1; + } + + .ciSidebar { + display: none; + } + + .ciSidebar.visible { + display: flex; + position: fixed; + top: 64px; + left: 0; + bottom: 0; + width: 220px; + z-index: 200; + border-right: 1px solid var(--line); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.6); + } + + .ciCapabilityBar { + flex-wrap: wrap; + } + + .ciCapCard { + flex: 1 1 45%; + border-right: 1px solid rgba(45, 33, 26, 0.7); + border-bottom: 1px solid rgba(45, 33, 26, 0.7); + } + + .ciGraphToolbar { + flex-wrap: wrap; + gap: 0.4rem; + } + + .ciGraphFilters { + order: 3; + width: 100%; + margin-right: 0; + } + + .ciDataFlowNodes { + padding-bottom: 0.3rem; + } +} diff --git a/templates/agent-work-app/index.html b/templates/agent-work-app/index.html index 4a28bfa..f2efff6 100644 --- a/templates/agent-work-app/index.html +++ b/templates/agent-work-app/index.html @@ -8,6 +8,7 @@ +
    @@ -19,8 +20,8 @@
    Review queue - +
    @@ -65,14 +66,1092 @@

    Trackers

    + + + + +
    +
    + + +
    +
    +
    +

    Welcome to Cento Console

    +

    Industrial OS control plane for secure, reliable, and observable software delivery at scale.

    +
    +
    +
    System HealthHealthyAll systems nominal
    +
    Active Workflows8Monitored
    +
    Open Issues248Across all projects
    +
    Cluster Nodes24Online
    +
    Artifacts1,024Stored
    +
    +
    + +
    +
    +

    Software Delivery Hub

    +

    Unified platform for automated software delivery and quality control

    +
    + +
    + +
    +
    +

    Platform Overview

    +

    System at a glance

    +
    +
    +
    128Components registered
    +
    5,432Evidence items total
    +
    3Security alerts active
    +
    12Integrations connected
    +
    42%1.2 TB / 3 TB
    +
    18,529API calls in 24h
    +
    +
    + +
    +
    +

    Recent Activity

    + Latest Cento web app demo recorded + Factory run factory-autopilot-runtime-v1 completed + Issue #1000120 assigned to codex + Release evidence bundle generated for 2026.05.01 +
    +
    +

    System Health

    +

    All core services are operational

    +

    Cluster nodes: 24 online

    +

    No critical security alerts

    +

    Backup status: Successful

    +
    +
    +
    + + +
    +
    + + + - @@ -227,37 +1458,75 @@

    Factory Runs

    Cento Overview Getting started - Tool registry - Validation + Tool registry + Validation
    - Taskstream - Overview - Agent - Issues - Review - Logs - Decision - Webhooks - API Reference + Software Delivery Hub + Overview + Taskstream + Factory +
    + Dev Pipeline Studio +
    + Overview + Pipeline gallery + Template editor + Contracts visualization + Execution flow + Input documentation + Manifest explorer + Evidence explorer + Best practices +
    +
    + Parallel Execution
    + Research Center + Overview + Research Overview + Codebase Intelligence + Research report + Cento-native AI +
    +
    + Platform + Architecture + OCI image migration + Components + Security + CLI Reference +
    +
    Cluster - Overview - Nodes - Packages - Updates - Configuration + Overview + Nodes + Packages + Updates + Configuration
    -
    +
    Consulting - Overview - Services - Engagements - Reports + Overview + Services + Engagements + Reports +
    +
    + Apps +
    + Kanji a Day + +
    - + Need help? Contact our support team @@ -266,89 +1535,841 @@

    Factory Runs

    -
    +
    Docs Overview
    -
    +

    Cento Documentation

    Operator-facing reference for running Cento, validating work, and finding the right command surface without leaving the console.

    -
    +

    Explore by area

    -
    - - +
    +
    +
    + Software Delivery Hub +

    Delivery, validation, and pipeline surfaces

    +
    +
    +
    + +
    +
    + Research Center +

    Research, mapping, and code intelligence

    +
    + +
    + +
    +
    + Platform +

    Infrastructure, service areas, and apps

    +
    + +
    +
    +
    + +
    +
    + Docs + + Software Delivery Hub + + Dev Pipeline Studio + + Input documentation +
    + +
    +
    +

    Input documentation Template

    +

    Define the required input for AI task execution. Each input provides the context and constraints needed for the AI to perform the work and produce acceptable results.

    +
    +
    + + + +
    +
    + +
    +
    +
    +

    Inputs overview

    +

    Inputs are the required pieces of information provided to the AI to complete a task. All inputs are validated before task execution begins.

    +
    + +
    +
    5Total inputsRequired to run
    +
    4ConfiguredProvided by user
    +
    1MissingMust be provided
    +
    StructuredJSON format
    +
    + +
    +

    Elements of input

    +

    Each input element has a specific purpose in task execution.

    +
    +
    +
    Task objectiveUser goal, target behavior, and project outcome
    + Provided + Type: string + Required +
    +
    +
    Target surfaceApp, route, command, or file area the AI should change
    + Configured + Type: string + Required +
    +
    +
    Acceptance criteriaConcrete conditions that prove the task is complete
    + Configured + Type: string + Required +
    +
    +
    ConstraintsOwned paths, exclusions, style rules, and risk limits
    + Configured + Type: object + Required +
    +
    +
    Validation evidenceCommands, screenshots, review gates, and handoff proof
    + Missing + Type: object + Required +
    +
    +
    + + +
    + +
    +
    +

    Input format

    +

    Inputs are defined in JSON format and passed to the AI task executor.

    +
    + +
    +
    JSONStructured format
    +
    ValidatedSchema validation
    +
    RequiredAll fields must be present
    +
    + +
    +
    +
    +

    Example input (JSON)

    +

    Sample of a complete input configuration.

    +
    + +
    +
    {
    +  "task_objective": "Add health check endpoint to the user service",
    +  "target_surface": "services/user-service/src/health/",
    +  "acceptance_criteria": "Health endpoint returns 200 OK and includes service status",
    +  "constraints": {
    +    "owned_paths": ["services/user-service/**"],
    +    "excluded_paths": ["**/node_modules/**", "**/dist/**"],
    +    "style_rules": "Follow existing code style and conventions",
    +    "risk_limits": "No breaking changes, backward compatible"
    +  },
    +  "validation_evidence": {
    +    "commands": [
    +      "curl -f http://localhost:8080/health",
    +      "npm test -- --testPathPattern=health"
    +    ],
    +    "screenshots": ["health-endpoint-response.png"],
    +    "review_gates": ["unit_tests_pass", "code_review_required"],
    +    "handoff_proof": "Health endpoint documented and tested"
    +  }
    +}
    +

    The JSON structure must include all required fields. See individual field descriptions for details.

    +
    +
    +
    + +
    +
    +

    Input legend

    +

    Understanding the status and configuration of each input element.

    +
    +
    +
    Required

    Input must be provided. Task execution blocked if missing.

    +
    Provided

    Input has been supplied. Ready for task execution.

    +
    Configured

    Input configured with defaults. May be customized.

    +
    Missing

    Input not provided. Task execution blocked.

    +
    Type

    Data type and format. Validation rules apply.

    +
    +
    +
    + +
    +
    + Docs + + Software Delivery Hub + + Dev Pipeline Studio + + Template Editor +
    + +
    +
    +
    -

    Taskstream

    -

    Issues, review queues, validation evidence, journals, saved filters, and agent handoff status.

    + Dev Pipeline Studio +

    Template Editor

    +

    Configure reusable web delivery pipelines per project. Select a project, start from a template, then edit worker contracts, validators, receipts, and evidence flow in one place.

    +
    + Development + v1.0.0 + +
    - - - - +
    + +
    + +
    + +
    +
    + +
    +

    Project Dashboard

    +
    +
    StatusActive
    +
    Version1.0.0
    +
    Environmentlocal preview
    +
    Last Validation2026-05-02
    +
    Templates4 loaded
    +
    Execution ModelOrdered task steps
    +
    Budget Ceiling$3.00
    +
    +
    + +
    +
    +
    +

    About Template Editor

    +

    Template Editor is the pipeline configuration surface inside Dev Pipeline Studio. Operators select a project and template, configure worker contracts, required inputs, execution model, risk, and budget cap. Each saved draft produces a manifest-driven pipeline that agents execute end-to-end.

    +
    +
    Project + TemplateScope pipelines per project
    +
    Template Library4 built-in templates
    +
    Worker ContractsEditable ordered task steps
    +
    Required InputsValidated before execution
    +
    Save DraftWrites pipeline_manifest.json
    +
    Execution ModelsOrdered, parallel, sequential
    +
    + Input documentation +
    +
    + +
    +
    +

    Current Release

    +
    +
    Version
    1.0.0
    +
    Build
    1000114
    +
    Release Date
    2026-05-02
    +
    + Release Notes +
      +
    • Project-scoped pipeline selector with 4 templates loaded
    • +
    • Template editor with ordered task steps execution model
    • +
    • 5 required operator inputs: objective, surface, criteria, constraints, evidence
    • +
    • Manifest save draft writes validated pipeline_manifest.json
    • +
    • Pipeline status header: health, tasks, budget, and project metadata
    • +
    +
    +
    + +
    +

    System Architecture

    +
    +
    Template Selectorproject + template
    + +
    Worker Editorcontracts + inputs
    + +
    Pipeline Manifestpipeline_manifest.json
    + +
    Agent Executionordered task steps
    + +
    Evidence Bundlereceipts + artifacts
    +
    +
    + + +
    + +
    + Template Library +

    Four templates ship at v1.0.0: Generic easy-medium task (bounded code change pipeline), Doc page creation (hero, content sections, links, release notes, validation), Dashboard module (metrics, panels, actions, data adapters, screenshots), and Release evidence page (change summary, approvals, artifacts, costs, audit trail). Each ships with preconfigured worker contracts, validators, and required inputs.

    +
    +
    + +
    +
    + Docs + + Platform + + Parallel Execution +
    + +
    +
    + Platform / runner ecosystem +

    Parallel Execution Engine

    +

    Cento's current parallel runner is a contract-first local execution layer. It runs independent owned-path tasks concurrently, then returns every patch through one sequential integration and apply lane so evidence stays auditable.

    +
    + Local v1 shipped + API workers partial + Updated 2026-05-02 +
    +
    + +
    + +
    +
    StatusShipped local runnerWorkset v1 above Cento Build
    +
    Parallel boundaryWorker collectionIntegration and apply stay sequential
    +
    API cap6 requestsConfigured max parallel OpenAI requests
    +
    Budget guard$3 / $5Default target and hard max
    +
    Local profiles3 configuredcodex-fast, fixture-valid, python-fixture
    +
    + +
    +
    -

    Factory

    -

    Manifest-driven queues, runtime adapters, owned-path leases, dry-run integration, and release evidence.

    + Execution flow +

    What actually runs in parallel

    - - - - +

    Workers run together only while collecting proposed patches or structured artifacts. Repo mutation remains local and serialized.

    +
    +
    +
    + 1 + Plan and lease +

    workset.json declares tasks, explicit write_paths, simple dependencies, and a maximum parallelism cap.

    +
    +
    + 2 + Run workers +

    Fixture, local-command, or API workers run ready tasks concurrently when their dependencies have completed.

    +
    +
    + 3 + Collect artifacts +

    Local workers produce patch bundles. API workers produce structured JSON only; they never write repo files directly.

    +
    +
    + 4 + Integrate sequentially +

    cento build integrate checks ownership, protected paths, patch safety, and validation before acceptance.

    +
    +
    + 5 + Apply and record +

    Accepted patches apply one at a time, then Cento writes receipts, event logs, costs, and Taskstream evidence.

    +
    +
    +
    + +
    + +
    +
    + Done +

    Single build package

    +

    cento build creates manifest-owned work packages, builder prompts, worker artifacts, patch bundles, integration receipts, apply receipts, validation receipts, and Taskstream evidence.

    +
    +
    + Done +

    Runtime profiles

    +

    cento runtime validates local profiles with argv arrays, scrubbed environment allowlists, timeouts, changed-file caps, and patch-line caps.

    +
    +
    + Done +

    Local workset runner

    +

    cento workset run executes exclusive-path tasks in isolated worktrees, then dry-runs and applies accepted patches through Cento Build.

    +
    +
    + Done +

    Structured execute path

    +

    cento workset execute supports fixture, local-command, and api-openai runtime families with dependency gates and sequential integration.

    +
    +
    + Done +

    API materialization guard

    +

    OpenAI workers return schema-checked JSON artifacts. A local materializer converts owned path contents into patch bundles and rejects unowned or protected paths.

    +
    +
    + Done +

    Budget and evidence receipts

    +

    API runs reserve cost before dispatch, enforce configured hard caps, and write cost receipts, worker receipts, workset receipts, evidence JSON, and events.ndjson.

    +
    +
    +
    + +
    + +
    +
    + Factory runtime adapters +

    noop, local-shell-fixture, and codex-dry-run define prepare, launch, status, collect, and cancel contracts. Runtime v1 is contract-first and does not enable broad live execution.

    +
    +
    + Factory integration +

    Factory can plan, lease, collect, validate, dry-run integrate, prepare integration worktrees, apply one patch at a time, and render release evidence. It does not merge to main.

    +
    +
    + Agent pool kicker +

    cento agent-pool-kick can keep Taskstream builder, validator, small-task, and coordinator lanes moving with max-launch guardrails. It is not the same thing as a workset cloud scheduler.

    +
    +
    + Dev Pipeline Studio +

    The Studio visualizes worker contracts, claimed paths, validators, receipts, and evidence flow. It is a control surface, not the execution engine itself.

    +
    +
    +
    + +
    + +
    +
    No cloud worker pool in worksetWorkset execution is local. API workers call OpenAI for structured output, but repo mutation still happens locally.
    +
    No OpenAI Batch orchestrationThe API path uses Responses requests with retry limits; it does not submit or reconcile Batch API jobs.
    +
    No automatic task splitting from a screenshotManifests must be authored by the operator, Factory, or another planning layer before workset execution.
    +
    No shared-file parallel mergeOverlapping paths, glob write paths, and protected paths are rejected. Shared-file work must be a separate serialized integrator task.
    +
    No smart conflict resolverIf a patch fails policy or git apply --check, the affected task blocks while independent tasks can continue.
    +
    No automatic PR or main mergeBuild, Workset, and Factory write evidence and integration artifacts; they do not open PRs or merge releases automatically.
    +
    No automatic Taskstream DoneFactory sync previews Review or Blocked transitions. Human review and explicit status updates still close the loop.
    +
    +
    + +
    + +
    + .cento/worksets/<run_id>/workset.json + .cento/worksets/<run_id>/leases.json + .cento/worksets/<run_id>/workset_receipt.json + .cento/worksets/<run_id>/workset_evidence.json + .cento/worksets/<run_id>/events.ndjson + .cento/worksets/<run_id>/workers/<worker_id>/artifact.json + .cento/worksets/<run_id>/workers/<worker_id>/cost_receipt.json + .cento/builds/workset_<run_id>_<task_id>/integration_receipt.json + .cento/builds/workset_<run_id>_<task_id>/apply_receipt.json + .cento/builds/workset_<run_id>_<task_id>/taskstream_evidence.json +
    +
    + +
    + +
    cento workset check tests/fixtures/cento_workset/workset.valid.json
    +cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke
    +cento workset execute workset.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke
    +cento workset materialize-artifact .cento/worksets/<run_id>/workers/<worker_id>/artifact.json
    +
    + +
    + Use It For +

    Docs pages, generated content slices, independent modules, validation experiments, and refactors where each worker owns explicit files. Do not use it for a single shared file, hidden global state, production deploys, or anything that needs cross-node execution without a higher-level Factory or Taskstream coordinator.

    +
    +
    + +
    +
    + Docs + + Apps + + Kanji a Day +
    + +
    +
    + +
    + App control surface +

    Kanji a Day

    +

    One kanji per day with stroke order, meaning, local progress, and history.

    +
    + Development + v0.3.0 + +
    +
    +
    + +
    +
    +
    +
    4/410:09
    +
    +
    ReplayMeaning
    +
    +
    +
    + + +
    + +
    +

    Project Dashboard

    +
    +
    StatusDevelopment
    +
    Version0.3.0
    +
    Environmentlocal preview
    +
    Last Validation2026-05-01
    +
    Daily Lessons1
    +
    Kanji Set7
    +
    SubscriptionsNot enabled
    +
    +
    + +
    +
    +
    +

    About Kanji a Day

    +

    Kanji a Day delivers one beginner kanji each day through a compact watch-style learning loop. The system teaches shape first, then meaning, then stores the lesson in local history.

    +
    +
    Stroke OrderSequential SVG playback
    +
    Daily LearningOne kanji per day
    +
    Meaning RevealMeaning, reading, example
    +
    Local-FirstProgress in localStorage
    +
    HistoryLearned cards and detail view
    +
    Debug ModeControls behind ?debug=1
    +
    + User Guide +
    +
    + +
    +
    +

    Current Release

    +
    +
    Version
    0.3.0
    +
    Build
    1000104
    +
    Release Date
    2026-05-01
    +
    + Release Notes +
      +
    • Watch-style daily kanji practice surface
    • +
    • Stroke playback with replay and completion state
    • +
    • Meaning reveal with reading and example word
    • +
    • Local progression, streak, and learned history
    • +
    • Normal mode hides internal debug controls
    • +
    + View Changelog +
    + +
    +

    Data Model

    +

    The embedded starter set is , , , , , , and . Each record carries meaning, reading, example vocabulary, stroke count, and SVG stroke paths.

    +
    + not_started + + practicing + + stroke_complete + + meaning_viewed + + learned +
    +
    +
    + +
    +

    System Architecture

    +
    +
    PWA Previewwatch-style UI
    + +
    Stroke PlayerSVG paths
    + +
    Kanji Dataset7 local records
    + +
    Local Storagehistory + streak
    +
    +
    + +
    + +
    + Validation Focus +

    Check Firefox responsive widths at 360px, 390px, and 430px. The watch header must not overlap, controls must stay inside the rounded frame, and the Meaning screen must center the kanji while keeping Got it visible.

    +
    -
    +

    Recent updates

    +
    OCI image migration guide added
    +
    Cento-native AI rework docs page added
    +
    Pipeline Studio Template Editor docs page added
    +
    Parallel Execution Engine docs page added
    +
    Kanji a Day docs page added
    Factory runtime adapters v1
    Factory autopilot control loop v1
    Docs and Research Center routes split
    -
    +
    ?

    Can't find what you're looking for?

    @@ -360,10 +2381,21 @@

    Can't find what you're looking for?

    @@ -457,7 +2489,7 @@

    4.2 Semantic Conventions for AI Workloads

    -