agent-toolkit-cli 1.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_toolkit/__init__.py +4 -0
- agent_toolkit/__main__.py +3 -0
- agent_toolkit/_paths.py +64 -0
- agent_toolkit/cli/__init__.py +0 -0
- agent_toolkit/cli/doctor.py +453 -0
- agent_toolkit/cli/install.py +445 -0
- agent_toolkit/cli/main.py +66 -0
- agent_toolkit/cli/mcp.py +418 -0
- agent_toolkit/cli/plugin.py +213 -0
- agent_toolkit/cli/skills.py +421 -0
- agent_toolkit/loop/__init__.py +0 -0
- agent_toolkit/loop/gh_gate.py +626 -0
- agent_toolkit/loop/runner.py +1548 -0
- agent_toolkit/runner/__init__.py +0 -0
- agent_toolkit/runner/dispatcher.py +164 -0
- agent_toolkit/runner/policy.py +345 -0
- agent_toolkit/runner/providers/__init__.py +21 -0
- agent_toolkit/runner/providers/anthropic_provider.py +109 -0
- agent_toolkit/runner/providers/base.py +53 -0
- agent_toolkit/runner/providers/ollama_provider.py +108 -0
- agent_toolkit/runner/providers/openai_provider.py +105 -0
- agent_toolkit/runner/providers/opencode_provider.py +127 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/architect/AGENT.md +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/assistant/AGENT.md +59 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/build-error-resolver/AGENT.md +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/client-workflow-bootstrap/AGENT.md +60 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/code-reviewer/AGENT.md +49 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/database-reviewer/AGENT.md +44 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/docs-lookup/AGENT.md +28 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/e2e-runner/AGENT.md +38 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/performance-optimizer/AGENT.md +52 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/planner/AGENT.md +41 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/refactor-cleaner/AGENT.md +43 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/reference-lookup/AGENT.md +33 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/security-reviewer/AGENT.md +47 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/tdd-guide/AGENT.md +58 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/tech-assistant/AGENT.md +11 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/typescript-reviewer/AGENT.md +61 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/catalogs/agent-catalog.yaml +202 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/catalogs/loop-catalog.yaml +165 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/catalogs/skill-catalog.yaml +699 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/catalogs/skills-layout.json +337 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/changelog-drafter/loop.yaml +40 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/ci-sweeper/loop.yaml +53 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/daily-triage/loop.yaml +47 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/dep-sweeper/loop.yaml +46 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/issue-triage/loop.yaml +40 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/oss-daily-briefing/loop.yaml +65 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/oss-pr-monitor/loop.yaml +85 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/oss-triage/loop.yaml +67 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/post-merge-cleanup/loop.yaml +44 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/pr-babysitter/loop.yaml +46 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/clickup/README.md +45 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/clickup/config.template.json +7 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/clickup/wrapper.sh +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/figma/README.md +56 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/figma/config.template.json +9 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/github/README.md +11 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/github/config.template.json +7 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/github/wrapper.sh +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/linear/README.md +49 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/linear/config.template.json +10 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/notion/README.md +7 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/notion/config.template.json +7 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/notion/wrapper.sh +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/slack/README.md +8 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/slack/config.template.json +8 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/slack/wrapper.sh +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/CLAUDE.md +66 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/assistant.md +59 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/build-error-resolver.md +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/client-workflow-bootstrap.md +60 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/contribution-planner.md +39 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/database-reviewer.md +44 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/docs-lookup.md +28 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/e2e-runner.md +38 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/performance-optimizer.md +52 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/planner.md +41 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/refactor-cleaner.md +43 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/reference-lookup.md +33 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/security-reviewer.md +47 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/tdd-guide.md +58 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/typescript-reviewer.md +61 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/settings.json +87 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/copilot/copilot-instructions.md +15 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/README.md +33 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/architect.mdc +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/assistant.mdc +25 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/build-error-resolver.mdc +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/code-reviewer.mdc +21 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/database-reviewer.mdc +29 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/docs-lookup.mdc +15 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/e2e-runner.mdc +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/performance-optimizer.mdc +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/planner.mdc +28 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/refactor-cleaner.mdc +26 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/reference-lookup.mdc +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/security-reviewer.mdc +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/tdd-guide.mdc +26 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/typescript-reviewer.mdc +28 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/architect.md +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/assistant.md +52 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/build-error-resolver.md +31 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/client-workflow-bootstrap.md +66 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/code-reviewer.md +47 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/contribution-planner.md +44 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/database-reviewer.md +43 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/docs-lookup.md +28 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/e2e-runner.md +33 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/performance-optimizer.md +34 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/planner.md +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/refactor-cleaner.md +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/reference-lookup.md +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/security-reviewer.md +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/tdd-guide.md +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/typescript-reviewer.md +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/opencode.json +37 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/architect/skill.md +5 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/assistant/skill.md +3 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/code-reviewer/skill.md +5 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/planner/skill.md +3 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/security-reviewer/skill.md +3 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/memories/global_rules.md +74 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/architect.mdc +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/build-error-resolver.mdc +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/code-reviewer.mdc +21 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/database-reviewer.mdc +29 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/docs-lookup.mdc +15 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/e2e-runner.mdc +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/performance-optimizer.mdc +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/planner.mdc +28 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/refactor-cleaner.mdc +26 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/security-reviewer.mdc +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/tdd-guide.mdc +26 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/typescript-reviewer.mdc +28 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/assistant/SKILL.md +213 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/assistant/references/AGENTS_TEMPLATE.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/assistant/references/ORCHESTRATION.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/assistant/references/REPO_INSPECTION.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/dev-companion/SKILL.md +65 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/dev-companion/references/LOOP_GUARDRAILS.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/onboarding/SKILL.md +119 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/output-handshake/SKILL.md +24 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/pr-fallback/SKILL.md +33 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/pr-fallback/references/pr-body-default.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/workspace-knowledge-sync/SKILL.md +141 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/data/dbt-validation/SKILL.md +41 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/data/snowflake-validation/SKILL.md +38 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/adr/SKILL.md +46 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/adr/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/adr/references/example-001-graphql-adoption.md +66 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/agreement/SKILL.md +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/agreement/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/agreement/references/example-api-versioning.md +29 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/bug/SKILL.md +25 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/bug/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/bug/references/example-search-filter-bug.md +58 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/decision-log/SKILL.md +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/decision-log/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/decision-log/references/example-repository-pattern.md +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/development-workflow/SKILL.md +33 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/development-workflow/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/development-workflow/references/default-workflow-checklist.md +44 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/epic/SKILL.md +21 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/epic/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/epic/references/example-unified-dashboard.md +40 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/incident/SKILL.md +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/incident/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/incident/references/example-payment-outage.md +62 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/management-unit-assessment/SKILL.md +105 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/management-unit-assessment/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/management-unit-assessment/references/example-team-assessment.md +126 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/meeting-minutes/SKILL.md +24 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/meeting-minutes/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/meeting-minutes/references/example-weekly-sync.md +72 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/planning/SKILL.md +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/planning/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/planning/references/example-sprint-planning.md +62 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/prd/SKILL.md +44 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/prd/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/prd/references/example-unified-api.md +71 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment/SKILL.md +90 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment/references/example-platform-assessment.md +122 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment-evidence/SKILL.md +88 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment-evidence/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment-evidence/references/example-evidence-map.md +46 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/spike/SKILL.md +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/spike/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/spike/references/example-graphql-federation.md +79 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/task/SKILL.md +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/task/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/task/references/example-nav-system.md +33 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/technical-unit-assessment/SKILL.md +58 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/technical-unit-assessment/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/technical-unit-assessment/references/example-frontend-assessment.md +112 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/technical-unit-assessment/references/indicator-groups.md +65 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/trd/SKILL.md +47 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/trd/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/trd/references/example-api-migration.md +122 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/user-story/SKILL.md +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/user-story/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/user-story/references/example-advanced-filtering.md +37 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/work-item/SKILL.md +30 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/work-item/references/default-template.md +0 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/work-item/references/example-routing-dashboard.md +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/workflow-client-bootstrap/SKILL.md +148 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/workflow-client-bootstrap/questions.yaml +165 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/workflow-generic-project/SKILL.md +78 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/LICENSE.txt +2 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/NOTICE.txt +25 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/SKILL.md +95 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/references/figma-mcp-config.md +82 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/references/figma-tools-and-prompts.md +34 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/LICENSE.txt +2 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/NOTICE.txt +18 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/SKILL.md +350 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/references/mapping-checklist.md +7 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/scripts/normalize_node_id.py +25 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/LICENSE.txt +2 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/NOTICE.txt +19 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/SKILL.md +550 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/references/rule-template.md +15 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/scripts/check_agents_md.sh +9 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-new-file/LICENSE.txt +2 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-new-file/NOTICE.txt +17 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-new-file/SKILL.md +69 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-implement-design/LICENSE.txt +2 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-implement-design/NOTICE.txt +19 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-implement-design/SKILL.md +264 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/SKILL.md +60 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/charts.csv +26 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/colors.csv +97 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/icons.csv +101 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/landing.csv +31 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/products.csv +97 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/react-performance.csv +45 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/astro.csv +54 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/react.csv +54 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/shadcn.csv +61 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/vue.csv +50 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/styles.csv +68 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/typography.csv +58 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/ui-reasoning.csv +101 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/web-interface.csv +31 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/references/pre-delivery-checklist.md +39 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/references/search-reference.md +44 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/references/ui-rules.md +35 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/scripts/core.py +253 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/scripts/design_system.py +1067 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/scripts/search.py +114 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-address-comments/LICENSE.txt +202 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-address-comments/NOTICE.txt +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-address-comments/SKILL.md +69 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-address-comments/scripts/fetch_comments.py +237 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-contribution-planner/SKILL.md +157 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-contribution-planner/scripts/inspect_contributions.py +800 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-fix-ci/LICENSE.txt +201 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-fix-ci/NOTICE.txt +20 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-fix-ci/SKILL.md +71 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-fix-ci/scripts/inspect_pr_checks.py +509 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/github-cli-workflow/SKILL.md +49 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gitlab-cli-workflow/SKILL.md +44 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/workflow-client-bootstrap/SKILL.md +14 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/workflow-generic-project/SKILL.md +14 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/clickup-cli/SKILL.md +75 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/clickup-cli/references/docs-management.md +57 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/clickup-cli/references/task-management.md +131 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/clickup-cli/references/time-tracking.md +38 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/linear/LICENSE.txt +202 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/linear/NOTICE.txt +23 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/linear/SKILL.md +131 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/slack-assistant/SKILL.md +134 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/slack-cli/SKILL.md +105 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/slack-cli/references/INSTALLATION.md +42 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/loops/loop-runner/SKILL.md +90 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/ops/docs-generator/SKILL.md +81 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/ops/llm-cost-advisor/SKILL.md +83 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/ops/triage/SKILL.md +117 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/LICENSE.txt +201 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/NOTICE.txt +27 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/SKILL.md +142 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/assets/experiment-template.ipynb +110 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/assets/tutorial-template.ipynb +107 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/references/experiment-patterns.md +10 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/references/notebook-structure.md +17 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/references/quality-checklist.md +11 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/references/tutorial-patterns.md +9 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/scripts/new_notebook.py +130 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/LICENSE.txt +201 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/NOTICE.txt +29 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/SKILL.md +151 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/references/cli.md +116 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/references/workflows.md +98 -0
- agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/scripts/executable_playwright_cli.sh +25 -0
- agent_toolkit_cli-1.0.3.dist-info/METADATA +411 -0
- agent_toolkit_cli-1.0.3.dist-info/RECORD +310 -0
- agent_toolkit_cli-1.0.3.dist-info/WHEEL +4 -0
- agent_toolkit_cli-1.0.3.dist-info/entry_points.txt +2 -0
- agent_toolkit_cli-1.0.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1548 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""
|
|
7
|
+
bin/loop — Loop engineering CLI for AI Workspace.
|
|
8
|
+
|
|
9
|
+
A "loop" is a recurring AI-driven process with durable state, safety gates,
|
|
10
|
+
and cost budgets. Inspired by loop engineering principles (Cobus Greyling,
|
|
11
|
+
Boris Cherny, Peter Steinberger, Addy Osmani — 2026).
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
loop init <pattern> Scaffold a loop from a starter template
|
|
15
|
+
loop run <loop> [--force] Execute a loop run (one iteration; --force bypasses daily budget)
|
|
16
|
+
loop status [loop] Show loop status (all, or one)
|
|
17
|
+
loop audit [loop] Summarize past runs (success rate, cost)
|
|
18
|
+
loop cost <loop> Estimate cost for one run
|
|
19
|
+
loop schedule <loop> Install systemd/launchd timer
|
|
20
|
+
loop sync Regenerate knowledge todos from completed runs
|
|
21
|
+
loop help Show this help
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import platform
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import signal
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
# ── cancellation ──────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
_CANCELLED = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _handle_sig(signum: int, _frame) -> None:
|
|
45
|
+
global _CANCELLED
|
|
46
|
+
if _CANCELLED:
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
_CANCELLED = True
|
|
49
|
+
print(file=sys.stderr)
|
|
50
|
+
warn("Received signal — cancelling (press again to force exit)")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
signal.signal(signal.SIGINT, _handle_sig)
|
|
54
|
+
signal.signal(signal.SIGTERM, _handle_sig)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── paths ─────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
# WORKSPACE_ROOT: prefer env var (for users running from their own workspace),
|
|
60
|
+
# then fall back to the repo root where agent-toolkit is installed/cloned.
|
|
61
|
+
# When installed as a package via pip, data files live alongside the source.
|
|
62
|
+
def _find_repo_root() -> Path:
|
|
63
|
+
"""Walk up from this file to find the repo root (where loops/ lives)."""
|
|
64
|
+
here = Path(__file__).resolve().parent
|
|
65
|
+
for candidate in [here.parent.parent.parent, here.parent.parent, here.parent]:
|
|
66
|
+
if (candidate / "loops").is_dir():
|
|
67
|
+
return candidate
|
|
68
|
+
# Fallback to CWD
|
|
69
|
+
return Path.cwd()
|
|
70
|
+
|
|
71
|
+
WORKSPACE_ROOT = Path(
|
|
72
|
+
os.environ.get("AGENT_TOOLKIT_ROOT", "")
|
|
73
|
+
or os.environ.get("AI_WORKSPACE", "")
|
|
74
|
+
or _find_repo_root()
|
|
75
|
+
)
|
|
76
|
+
LOOPS_DIR = WORKSPACE_ROOT / "loops"
|
|
77
|
+
TEMPLATES_DIR = WORKSPACE_ROOT / "loops" # templates are the loops/ dir itself
|
|
78
|
+
WORKTREES_HOME = Path(
|
|
79
|
+
os.environ.get("HARNESS_WORKTREES_DIR", "")
|
|
80
|
+
or Path.home() / ".local" / "share" / "agent-toolkit" / "worktrees"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Devcompanion queue paths (shared with bin/devcompanion)
|
|
84
|
+
_DC_HOME = Path(
|
|
85
|
+
os.environ.get("HARNESS_DC_HOME")
|
|
86
|
+
or Path.home() / ".local" / "share" / "agent-toolkit" / "dev-companion"
|
|
87
|
+
)
|
|
88
|
+
_QUEUE_PENDING = _DC_HOME / "queue" / "pending"
|
|
89
|
+
_QUEUE_DONE = _DC_HOME / "queue" / "done"
|
|
90
|
+
_QUEUE_FAILED = _DC_HOME / "queue" / "failed"
|
|
91
|
+
|
|
92
|
+
# ── colors ────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
_USE_COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _c(code: str, text: str) -> str:
|
|
98
|
+
return f"\033[{code}m{text}\033[0m" if _USE_COLOR else text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def blue(t: str) -> str:
|
|
102
|
+
return _c("1;34", t)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def green(t: str) -> str:
|
|
106
|
+
return _c("1;32", t)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def yellow(t: str) -> str:
|
|
110
|
+
return _c("1;33", t)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def red(t: str) -> str:
|
|
114
|
+
return _c("1;31", t)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def dim(t: str) -> str:
|
|
118
|
+
return _c("0;37", t)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def log(msg: str) -> None:
|
|
122
|
+
print(f"{blue('[loop]')} {msg}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def ok(msg: str) -> None:
|
|
126
|
+
print(f"{green('[loop]')} {msg}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def warn(msg: str) -> None:
|
|
130
|
+
print(f"{yellow('[loop]')} {msg}", file=sys.stderr)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def err(msg: str) -> None:
|
|
134
|
+
print(f"{red('[loop]')} {msg}", file=sys.stderr)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def utc_now() -> str:
|
|
140
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_id() -> str:
|
|
144
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
145
|
+
import uuid
|
|
146
|
+
return f"{ts}-{uuid.uuid4().hex[:6]}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_md_frontmatter(md_path: Path) -> dict[str, Any]:
|
|
150
|
+
"""Parse YAML frontmatter from any markdown file."""
|
|
151
|
+
if not md_path.exists():
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
text = md_path.read_text(encoding="utf-8")
|
|
155
|
+
lines = text.splitlines()
|
|
156
|
+
if not lines or lines[0].strip() != "---":
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
end = next((i for i, l in enumerate(lines[1:], 1) if l.strip() == "---"), None)
|
|
160
|
+
if end is None:
|
|
161
|
+
return {}
|
|
162
|
+
|
|
163
|
+
yaml_block = "\n".join(lines[1:end])
|
|
164
|
+
try:
|
|
165
|
+
import yaml # type: ignore
|
|
166
|
+
return yaml.safe_load(yaml_block) or {}
|
|
167
|
+
except ImportError:
|
|
168
|
+
return _parse_simple_yaml(yaml_block)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_loop_md(loop_dir: Path) -> dict[str, Any]:
|
|
172
|
+
"""Parse loop config from LOOP.md frontmatter or loop.yaml."""
|
|
173
|
+
loop_md = loop_dir / "LOOP.md"
|
|
174
|
+
loop_yaml = loop_dir / "loop.yaml"
|
|
175
|
+
if loop_md.exists():
|
|
176
|
+
return parse_md_frontmatter(loop_md)
|
|
177
|
+
if loop_yaml.exists():
|
|
178
|
+
text = loop_yaml.read_text()
|
|
179
|
+
return _parse_simple_yaml(text)
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_simple_yaml(text: str) -> dict[str, Any]:
|
|
184
|
+
"""Minimal YAML parser for loop frontmatter (no PyYAML required)."""
|
|
185
|
+
result: dict[str, Any] = {}
|
|
186
|
+
current_key: str | None = None
|
|
187
|
+
list_items: list[str] = []
|
|
188
|
+
in_budget = False
|
|
189
|
+
budget: dict[str, Any] = {}
|
|
190
|
+
|
|
191
|
+
for line in text.splitlines():
|
|
192
|
+
stripped = line.strip()
|
|
193
|
+
if not stripped or stripped.startswith("#"):
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
if stripped == "budget:":
|
|
197
|
+
in_budget = True
|
|
198
|
+
current_key = "budget"
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
if in_budget and line.startswith(" ") and ":" in stripped:
|
|
202
|
+
k, _, v = stripped.partition(":")
|
|
203
|
+
try:
|
|
204
|
+
budget[k.strip()] = int(v.strip())
|
|
205
|
+
except ValueError:
|
|
206
|
+
budget[k.strip()] = v.strip()
|
|
207
|
+
continue
|
|
208
|
+
else:
|
|
209
|
+
in_budget = False
|
|
210
|
+
if "budget" in result and isinstance(result.get("budget"), dict):
|
|
211
|
+
pass
|
|
212
|
+
elif current_key == "budget":
|
|
213
|
+
result["budget"] = budget
|
|
214
|
+
budget = {}
|
|
215
|
+
|
|
216
|
+
if stripped.startswith("- ") and current_key:
|
|
217
|
+
list_items.append(stripped[2:].strip().strip('"').strip("'"))
|
|
218
|
+
result[current_key] = list_items[:]
|
|
219
|
+
continue
|
|
220
|
+
elif ":" in stripped and not stripped.startswith("-"):
|
|
221
|
+
if current_key and isinstance(result.get(current_key), list):
|
|
222
|
+
list_items = []
|
|
223
|
+
k, _, v = stripped.partition(":")
|
|
224
|
+
current_key = k.strip()
|
|
225
|
+
val = v.strip().strip('"').strip("'")
|
|
226
|
+
if val:
|
|
227
|
+
result[current_key] = val
|
|
228
|
+
else:
|
|
229
|
+
list_items = []
|
|
230
|
+
result[current_key] = list_items
|
|
231
|
+
|
|
232
|
+
if current_key == "budget":
|
|
233
|
+
result["budget"] = budget
|
|
234
|
+
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def parse_state_md(loop_dir: Path) -> dict[str, Any]:
|
|
239
|
+
"""Parse STATE.md frontmatter."""
|
|
240
|
+
return parse_md_frontmatter(loop_dir / "STATE.md")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def write_state_md(loop_dir: Path, state: dict[str, Any]) -> None:
|
|
244
|
+
"""Write STATE.md with updated frontmatter.
|
|
245
|
+
|
|
246
|
+
List/string values that contain '#' or ':' are quoted so PyYAML / the
|
|
247
|
+
simple parser cannot treat PR numbers like ``#136`` as comments.
|
|
248
|
+
"""
|
|
249
|
+
state_md = loop_dir / "STATE.md"
|
|
250
|
+
|
|
251
|
+
def _fmt_scalar(v: Any) -> str:
|
|
252
|
+
if v is None:
|
|
253
|
+
return ""
|
|
254
|
+
if isinstance(v, bool):
|
|
255
|
+
return "true" if v else "false"
|
|
256
|
+
if isinstance(v, (int, float)):
|
|
257
|
+
return str(v)
|
|
258
|
+
# Always JSON-quote strings so PyYAML cannot coerce true/null/dates.
|
|
259
|
+
return json.dumps(str(v), ensure_ascii=False)
|
|
260
|
+
|
|
261
|
+
lines = ["---"]
|
|
262
|
+
for k, v in state.items():
|
|
263
|
+
if isinstance(v, list):
|
|
264
|
+
lines.append(f"{k}:")
|
|
265
|
+
for item in v:
|
|
266
|
+
lines.append(f" - {_fmt_scalar(item)}")
|
|
267
|
+
elif isinstance(v, dict):
|
|
268
|
+
lines.append(f"{k}:")
|
|
269
|
+
for dk, dv in v.items():
|
|
270
|
+
lines.append(f" {dk}: {_fmt_scalar(dv)}")
|
|
271
|
+
else:
|
|
272
|
+
lines.append(f"{k}: {_fmt_scalar(v)}")
|
|
273
|
+
lines.append("---")
|
|
274
|
+
lines.append("")
|
|
275
|
+
state_md.write_text("\n".join(lines), encoding="utf-8")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def list_loops() -> list[Path]:
|
|
279
|
+
"""Return all loop directories sorted by name. Supports both LOOP.md and loop.yaml."""
|
|
280
|
+
if not LOOPS_DIR.is_dir():
|
|
281
|
+
return []
|
|
282
|
+
return sorted([
|
|
283
|
+
d for d in LOOPS_DIR.iterdir()
|
|
284
|
+
if d.is_dir() and ((d / "LOOP.md").exists() or (d / "loop.yaml").exists())
|
|
285
|
+
])
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def list_templates() -> list[Path]:
|
|
289
|
+
"""Return available loop templates from loops/ directory."""
|
|
290
|
+
if not TEMPLATES_DIR.is_dir():
|
|
291
|
+
return []
|
|
292
|
+
# Each loop dir contains a loop.yaml — use those as templates
|
|
293
|
+
results = []
|
|
294
|
+
for d in TEMPLATES_DIR.iterdir():
|
|
295
|
+
if d.is_dir() and (d / "loop.yaml").exists():
|
|
296
|
+
results.append(d / "loop.yaml")
|
|
297
|
+
# Also check for flat *.yaml files (legacy format)
|
|
298
|
+
results.extend(f for f in TEMPLATES_DIR.glob("*.yaml") if f not in results)
|
|
299
|
+
return sorted(results)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ── autonomy / prompt assembly ────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
def _as_str_list(value: Any) -> list[str]:
|
|
305
|
+
if value is None:
|
|
306
|
+
return []
|
|
307
|
+
if isinstance(value, list):
|
|
308
|
+
return [str(x) for x in value]
|
|
309
|
+
if isinstance(value, str) and value.strip():
|
|
310
|
+
return [value.strip()]
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _load_request_body(loop_dir: Path, meta: dict[str, Any], loop_name: str) -> str:
|
|
315
|
+
"""Prefer request.md; fall back to LOOP.md goal / generic execute line."""
|
|
316
|
+
request_md = loop_dir / "request.md"
|
|
317
|
+
if request_md.exists():
|
|
318
|
+
body = request_md.read_text(encoding="utf-8").strip()
|
|
319
|
+
if body:
|
|
320
|
+
return body
|
|
321
|
+
if meta.get("request"):
|
|
322
|
+
return str(meta["request"]).strip()
|
|
323
|
+
goal = meta.get("goal")
|
|
324
|
+
if goal:
|
|
325
|
+
return f"Execute the '{loop_name}' loop.\n\nGoal: {goal}"
|
|
326
|
+
return f"Execute the '{loop_name}' loop."
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _autonomy_contract(meta: dict[str, Any], loop_dir: Path) -> str:
|
|
330
|
+
"""Hard constraints injected into every runner prompt (not honor-system only)."""
|
|
331
|
+
tier = str(meta.get("tier", "L1")).upper()
|
|
332
|
+
allow = _as_str_list(meta.get("allowlist"))
|
|
333
|
+
deny = _as_str_list(meta.get("deny"))
|
|
334
|
+
verifier = meta.get("verifier") or "(none — treat all mutating actions as escalations)"
|
|
335
|
+
state = parse_state_md(loop_dir)
|
|
336
|
+
pending = _as_str_list(state.get("pending"))
|
|
337
|
+
escalations = _as_str_list(state.get("escalations"))
|
|
338
|
+
|
|
339
|
+
lines = [
|
|
340
|
+
"## Autonomy contract (HARD — violate = human_escalation)",
|
|
341
|
+
f"- Tier: {tier}",
|
|
342
|
+
f"- Allowlist (may do without asking): {', '.join(allow) if allow else '(empty — no mutations)'}",
|
|
343
|
+
f"- Deny (never do): {', '.join(deny) if deny else '(none listed)'}",
|
|
344
|
+
f"- Verifier for mutating actions: {verifier}",
|
|
345
|
+
"",
|
|
346
|
+
"Tier rules:",
|
|
347
|
+
]
|
|
348
|
+
if tier.startswith("L1") or tier == "1":
|
|
349
|
+
lines.append(
|
|
350
|
+
"- L1 report-only: READ and write plan.md/report.md/STATE.md only. "
|
|
351
|
+
"Do NOT comment, label, assign, merge, close, push, commit, or approve."
|
|
352
|
+
)
|
|
353
|
+
elif tier.startswith("L2") or tier == "2":
|
|
354
|
+
lines.append(
|
|
355
|
+
"- L2 assisted: only allowlisted mutations (typically comment/label/assign). "
|
|
356
|
+
"Never merge/close/push/commit/approve. Unsure → escalate, do not act."
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
lines.append(
|
|
360
|
+
"- L3 unattended: only allowlisted mutations. Deny list is absolute. "
|
|
361
|
+
"Human PRs: comment only — never merge/close. Unsure → escalate."
|
|
362
|
+
)
|
|
363
|
+
lines.append(
|
|
364
|
+
f"- HARD GATE: `gh` is wrapped by `bin/loop-gh-gate`. merge/close require a "
|
|
365
|
+
f"verifier receipt under `{loop_dir.name}/runs/<id>/verifier-receipts/`."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
lines += [
|
|
369
|
+
"",
|
|
370
|
+
"Hard gate (enforced by PATH shim — not honor-system):",
|
|
371
|
+
"- Mutating `gh` commands are intercepted. Denied actions exit with code 78 "
|
|
372
|
+
"(from the intercepted `gh` process).",
|
|
373
|
+
"- For merge/close: an *independent* verifier must write a receipt JSON "
|
|
374
|
+
"(do NOT self-approve as the maker):",
|
|
375
|
+
" `$OUTPUT_DIR/verifier-receipts/<slug>.json` with keys:",
|
|
376
|
+
' `action`, `repo` (owner/name), `number`, `approved` (true), '
|
|
377
|
+
f'`verifier` ("{verifier}"), `rationale`, `ts` (ISO8601 Z).',
|
|
378
|
+
"- When `LOOP_GATE_RECEIPT_SECRET` is set, receipts must include "
|
|
379
|
+
"`sig` (HMAC-SHA256 hex of the canonical JSON payload).",
|
|
380
|
+
"- Receipts require exact repo + number + verifier match and expire after 1 hour.",
|
|
381
|
+
"",
|
|
382
|
+
"Before finishing:",
|
|
383
|
+
f"1. Update `{loop_dir / 'STATE.md'}` frontmatter `pending:` and `escalations:` "
|
|
384
|
+
"(replace with current lists; use empty lists if clear). "
|
|
385
|
+
"Quote every list item that contains `#` (PR/issue numbers), e.g. "
|
|
386
|
+
'`- "nanlabs/foo#123 — reason"`.',
|
|
387
|
+
"2. Write plan.md and report.md under the output directory.",
|
|
388
|
+
"3. If exiting with human_escalation, put the reason in escalations and report.md.",
|
|
389
|
+
]
|
|
390
|
+
if pending:
|
|
391
|
+
lines += ["", "Carried pending from last run:"] + [f"- {p}" for p in pending]
|
|
392
|
+
if escalations:
|
|
393
|
+
lines += ["", "Open escalations:"] + [f"- {e}" for e in escalations]
|
|
394
|
+
return "\n".join(lines)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _build_runner_prompt(
|
|
398
|
+
loop_dir: Path,
|
|
399
|
+
meta: dict[str, Any],
|
|
400
|
+
loop_name: str,
|
|
401
|
+
run_dir: Path,
|
|
402
|
+
) -> str:
|
|
403
|
+
request = _load_request_body(loop_dir, meta, loop_name)
|
|
404
|
+
contract = _autonomy_contract(meta, loop_dir)
|
|
405
|
+
return (
|
|
406
|
+
"You are executing an autonomous loop run in an agent-toolkit workspace.\n"
|
|
407
|
+
f"Workspace root: {WORKSPACE_ROOT}\n"
|
|
408
|
+
f"Loop directory: {loop_dir}\n"
|
|
409
|
+
f"Output directory: {run_dir}\n\n"
|
|
410
|
+
f"{contract}\n\n"
|
|
411
|
+
"---\n\n"
|
|
412
|
+
f"{request}\n\n"
|
|
413
|
+
"---\n\n"
|
|
414
|
+
f"Write your final report to {run_dir}/report.md and your plan to "
|
|
415
|
+
f"{run_dir}/plan.md. Work from the workspace root shown above."
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ── built-in runners ──────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
def _gate_script() -> Path:
|
|
422
|
+
return Path(__file__).resolve().parent / "loop-gh-gate"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _runner_env(run_dir: Path, meta: dict[str, Any]) -> dict[str, str]:
|
|
426
|
+
"""Env for runners: PATH-first `gh` shim enforcing allowlist/deny/receipts.
|
|
427
|
+
|
|
428
|
+
Fails closed: if the gate cannot be installed, raises RuntimeError so the
|
|
429
|
+
run aborts rather than dispatching with an ungated `gh`.
|
|
430
|
+
"""
|
|
431
|
+
import runpy
|
|
432
|
+
|
|
433
|
+
gate_path = _gate_script()
|
|
434
|
+
if not gate_path.exists():
|
|
435
|
+
raise RuntimeError(f"loop-gh-gate missing at {gate_path} — refusing ungated run")
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
mod = runpy.run_path(str(gate_path))
|
|
439
|
+
tier = str(meta.get("tier", "L1"))
|
|
440
|
+
allow = _as_str_list(meta.get("allowlist"))
|
|
441
|
+
deny = _as_str_list(meta.get("deny"))
|
|
442
|
+
verifier = str(meta.get("verifier") or "")
|
|
443
|
+
env = mod["install_gh_shim"](
|
|
444
|
+
run_dir,
|
|
445
|
+
tier=tier,
|
|
446
|
+
allowlist=allow,
|
|
447
|
+
deny=deny,
|
|
448
|
+
verifier=verifier,
|
|
449
|
+
gate_script=gate_path,
|
|
450
|
+
)
|
|
451
|
+
log(
|
|
452
|
+
f"Hard gate active: gh shim → allow=[{', '.join(allow) or '∅'}] "
|
|
453
|
+
f"deny=[{', '.join(deny) or '∅'}]"
|
|
454
|
+
)
|
|
455
|
+
return env
|
|
456
|
+
except Exception as exc: # noqa: BLE001
|
|
457
|
+
raise RuntimeError(f"failed to install gh gate ({exc}) — refusing ungated run") from exc
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _install_gate_into_environ(run_dir: Path, meta: dict[str, Any]) -> dict[str, str]:
|
|
461
|
+
"""Install the gh gate and apply its env to this process (and return a copy)."""
|
|
462
|
+
env = _runner_env(run_dir, meta)
|
|
463
|
+
# Mediate every subsequent child (agent-toolkit runner, etc.).
|
|
464
|
+
for key, value in env.items():
|
|
465
|
+
if key.startswith("LOOP_GATE_") or key == "PATH":
|
|
466
|
+
os.environ[key] = value
|
|
467
|
+
return env
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _try_claude_runner(prompt: str, run_dir: Path, meta: dict[str, Any] | None = None) -> bool:
|
|
471
|
+
"""Invoke `claude --print` as a built-in runner for Claude Code users."""
|
|
472
|
+
claude_bin = shutil.which("claude")
|
|
473
|
+
if not claude_bin:
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
env = _install_gate_into_environ(run_dir, meta or {})
|
|
477
|
+
try:
|
|
478
|
+
result = subprocess.run(
|
|
479
|
+
[claude_bin, "--print",
|
|
480
|
+
"--allowedTools", "Bash,Read,Write,Edit,Glob,Grep"],
|
|
481
|
+
input=prompt,
|
|
482
|
+
capture_output=True,
|
|
483
|
+
text=True,
|
|
484
|
+
cwd=str(WORKSPACE_ROOT),
|
|
485
|
+
timeout=900,
|
|
486
|
+
env=env,
|
|
487
|
+
)
|
|
488
|
+
except subprocess.TimeoutExpired:
|
|
489
|
+
warn("claude runner timed out (900s)")
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
if result.returncode == 0:
|
|
493
|
+
report_md = run_dir / "report.md"
|
|
494
|
+
if not report_md.exists() and result.stdout.strip():
|
|
495
|
+
report_md.write_text(result.stdout, encoding="utf-8")
|
|
496
|
+
ok("claude runner completed")
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
# Claude Code writes auth/error output to stdout, not stderr — show both.
|
|
500
|
+
error_output = (result.stderr + result.stdout).strip()[:400]
|
|
501
|
+
warn(f"claude runner exited {result.returncode}: {error_output or '(no output)'}")
|
|
502
|
+
if result.returncode == 1 and not error_output:
|
|
503
|
+
warn(" Hint: Claude Code may not be authenticated — run: claude /login")
|
|
504
|
+
return False
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _try_opencode_runner(prompt: str, run_dir: Path, meta: dict[str, Any] | None = None) -> bool:
|
|
508
|
+
"""Invoke `opencode run` as a headless runner."""
|
|
509
|
+
opencode_bin = shutil.which("opencode")
|
|
510
|
+
if not opencode_bin:
|
|
511
|
+
return False
|
|
512
|
+
|
|
513
|
+
env = _install_gate_into_environ(run_dir, meta or {})
|
|
514
|
+
try:
|
|
515
|
+
result = subprocess.run(
|
|
516
|
+
[opencode_bin, "run", "--print-logs"],
|
|
517
|
+
input=prompt,
|
|
518
|
+
capture_output=True,
|
|
519
|
+
text=True,
|
|
520
|
+
cwd=str(WORKSPACE_ROOT),
|
|
521
|
+
timeout=900,
|
|
522
|
+
env=env,
|
|
523
|
+
)
|
|
524
|
+
except subprocess.TimeoutExpired:
|
|
525
|
+
warn("opencode runner timed out (900s)")
|
|
526
|
+
return False
|
|
527
|
+
|
|
528
|
+
if result.returncode == 0:
|
|
529
|
+
report_md = run_dir / "report.md"
|
|
530
|
+
if not report_md.exists() and result.stdout.strip():
|
|
531
|
+
report_md.write_text(result.stdout, encoding="utf-8")
|
|
532
|
+
ok("opencode runner completed")
|
|
533
|
+
return True
|
|
534
|
+
|
|
535
|
+
error_output = (result.stderr + result.stdout).strip()[:400]
|
|
536
|
+
warn(f"opencode runner exited {result.returncode}: {error_output or '(no output)'}")
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _queue_via_devcompanion(
|
|
541
|
+
request: str,
|
|
542
|
+
run_dir: Path,
|
|
543
|
+
loop_name: str,
|
|
544
|
+
rid: str,
|
|
545
|
+
meta: dict[str, Any] | None = None,
|
|
546
|
+
) -> bool:
|
|
547
|
+
"""Queue the loop run as a devcompanion job for async processing.
|
|
548
|
+
|
|
549
|
+
The job will be picked up by the systemd worker (nanlabs-dev-companion-worker)
|
|
550
|
+
or via `devcompanion run-once`. Returns True if queued successfully.
|
|
551
|
+
|
|
552
|
+
This avoids the subprocess-in-subprocess problem when running `loop run`
|
|
553
|
+
from within an interactive LLM session (opencode, claude, etc.).
|
|
554
|
+
"""
|
|
555
|
+
# Install gate into this process and persist gate metadata for the worker.
|
|
556
|
+
gate_meta: dict[str, Any] = {}
|
|
557
|
+
if meta is not None:
|
|
558
|
+
env = _install_gate_into_environ(run_dir, meta)
|
|
559
|
+
gate_meta = {
|
|
560
|
+
"tier": str(meta.get("tier", "L1")),
|
|
561
|
+
"allowlist": _as_str_list(meta.get("allowlist")),
|
|
562
|
+
"deny": _as_str_list(meta.get("deny")),
|
|
563
|
+
"verifier": str(meta.get("verifier") or ""),
|
|
564
|
+
"run_dir": str(run_dir),
|
|
565
|
+
"real_gh": env.get("LOOP_GATE_REAL_GH", ""),
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
job_id = f"loop-{loop_name}-{rid}"
|
|
569
|
+
job = {
|
|
570
|
+
"id": job_id,
|
|
571
|
+
"created_at": utc_now(),
|
|
572
|
+
"request": request,
|
|
573
|
+
"repo_path": str(WORKSPACE_ROOT),
|
|
574
|
+
"loop_run_dir": str(run_dir),
|
|
575
|
+
"llm": True,
|
|
576
|
+
"limits": {
|
|
577
|
+
"timeout_sec": 1800,
|
|
578
|
+
"max_steps": 25,
|
|
579
|
+
},
|
|
580
|
+
"actions_allowed": ["plan_only"],
|
|
581
|
+
"loop_gate": gate_meta,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
job_file = _QUEUE_PENDING / f"{job_id}.job"
|
|
585
|
+
try:
|
|
586
|
+
job_file.parent.mkdir(parents=True, exist_ok=True)
|
|
587
|
+
job_file.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
|
|
588
|
+
log(f"Queued as devcompanion job: {job_id}")
|
|
589
|
+
log(f" Job file : {job_file}")
|
|
590
|
+
log(f" Run dir : {run_dir}")
|
|
591
|
+
log(f" Next step : the worker (systemd timer every 5m) will pick it up")
|
|
592
|
+
log(f" Or run : devcompanion run-once to process immediately")
|
|
593
|
+
log(f" Check : devcompanion status")
|
|
594
|
+
return True
|
|
595
|
+
except OSError as e:
|
|
596
|
+
warn(f"Could not queue devcompanion job: {e}")
|
|
597
|
+
return False
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ── commands ──────────────────────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
def cmd_init(args: list[str]) -> int:
|
|
603
|
+
"""Scaffold a new loop from a starter template."""
|
|
604
|
+
if not args:
|
|
605
|
+
err("Usage: loop init <pattern>")
|
|
606
|
+
print("\nAvailable patterns:")
|
|
607
|
+
for t in list_templates():
|
|
608
|
+
print(f" {t.stem}")
|
|
609
|
+
return 1
|
|
610
|
+
|
|
611
|
+
pattern = args[0]
|
|
612
|
+
template_file = TEMPLATES_DIR / f"{pattern}.yaml"
|
|
613
|
+
if not template_file.exists():
|
|
614
|
+
err(f"Template '{pattern}' not found.")
|
|
615
|
+
print("\nAvailable patterns:")
|
|
616
|
+
for t in list_templates():
|
|
617
|
+
print(f" {t.stem}")
|
|
618
|
+
return 1
|
|
619
|
+
|
|
620
|
+
# Parse the template for defaults
|
|
621
|
+
text = template_file.read_text(encoding="utf-8")
|
|
622
|
+
name_match = re.search(r"^name:\s+(\S+)", text, re.MULTILINE)
|
|
623
|
+
loop_name = name_match.group(1) if name_match else pattern
|
|
624
|
+
|
|
625
|
+
loop_dir = LOOPS_DIR / loop_name
|
|
626
|
+
if loop_dir.exists():
|
|
627
|
+
warn(f"Loop '{loop_name}' already exists at {loop_dir}")
|
|
628
|
+
return 1
|
|
629
|
+
|
|
630
|
+
loop_dir.mkdir(parents=True)
|
|
631
|
+
runs_dir = loop_dir / "runs"
|
|
632
|
+
runs_dir.mkdir()
|
|
633
|
+
|
|
634
|
+
# Build LOOP.md from template
|
|
635
|
+
loop_md = loop_dir / "LOOP.md"
|
|
636
|
+
loop_md.write_text(
|
|
637
|
+
"---\n"
|
|
638
|
+
+ "\n".join(
|
|
639
|
+
line for line in text.splitlines()
|
|
640
|
+
if not line.startswith("#") or line.strip() == "#"
|
|
641
|
+
)
|
|
642
|
+
+ "\n---\n\n"
|
|
643
|
+
f"# {loop_name.replace('-', ' ').title()} Loop\n\n"
|
|
644
|
+
"<!-- Describe the goal, constraints, and escalation rules here. -->\n",
|
|
645
|
+
encoding="utf-8",
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Build initial STATE.md
|
|
649
|
+
write_state_md(
|
|
650
|
+
loop_dir,
|
|
651
|
+
{
|
|
652
|
+
"last_run": "never",
|
|
653
|
+
"last_run_status": "not_run",
|
|
654
|
+
"last_run_id": "",
|
|
655
|
+
"runs_today": 0,
|
|
656
|
+
"pending": [],
|
|
657
|
+
"escalations": [],
|
|
658
|
+
},
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
ok(f"Initialized loop '{loop_name}' at {loop_dir.relative_to(WORKSPACE_ROOT)}")
|
|
662
|
+
print(f"\n Edit {loop_dir.relative_to(WORKSPACE_ROOT)}/LOOP.md to customize.")
|
|
663
|
+
print(f" Then run: ./bin/loop run {loop_name}")
|
|
664
|
+
return 0
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def cmd_run(args: list[str]) -> int:
|
|
668
|
+
"""Execute one loop run.
|
|
669
|
+
|
|
670
|
+
Usage: loop run <loop-name> [--force]
|
|
671
|
+
--force bypass max_runs_per_day budget gate
|
|
672
|
+
"""
|
|
673
|
+
if not args:
|
|
674
|
+
err("Usage: loop run <loop-name> [--force]")
|
|
675
|
+
return 1
|
|
676
|
+
|
|
677
|
+
force = "--force" in args
|
|
678
|
+
loop_name = next((a for a in args if not a.startswith("-")), "")
|
|
679
|
+
if not loop_name:
|
|
680
|
+
err("Usage: loop run <loop-name> [--force]")
|
|
681
|
+
return 1
|
|
682
|
+
|
|
683
|
+
loop_dir = LOOPS_DIR / loop_name
|
|
684
|
+
if not loop_dir.exists():
|
|
685
|
+
err(f"Loop '{loop_name}' not found. Run: ./bin/loop init {loop_name}")
|
|
686
|
+
return 1
|
|
687
|
+
|
|
688
|
+
meta = parse_loop_md(loop_dir)
|
|
689
|
+
tier = meta.get("tier", "L1")
|
|
690
|
+
budget = meta.get("budget", {})
|
|
691
|
+
max_runs = int(budget.get("max_runs_per_day", 10))
|
|
692
|
+
|
|
693
|
+
# Load current state
|
|
694
|
+
state_md = loop_dir / "STATE.md"
|
|
695
|
+
state = {
|
|
696
|
+
"last_run": "never",
|
|
697
|
+
"last_run_status": "not_run",
|
|
698
|
+
"last_run_id": "",
|
|
699
|
+
"runs_today": 0,
|
|
700
|
+
"pending": [],
|
|
701
|
+
"escalations": [],
|
|
702
|
+
}
|
|
703
|
+
if state_md.exists():
|
|
704
|
+
loaded = parse_state_md(loop_dir)
|
|
705
|
+
state.update(loaded)
|
|
706
|
+
|
|
707
|
+
# Budget gate: check runs_today
|
|
708
|
+
runs_today = int(state.get("runs_today", 0) or 0)
|
|
709
|
+
last_run_ts = state.get("last_run", "never")
|
|
710
|
+
if last_run_ts not in (None, "never", ""):
|
|
711
|
+
try:
|
|
712
|
+
# PyYAML may parse ISO timestamps as datetime objects
|
|
713
|
+
if hasattr(last_run_ts, "isoformat"):
|
|
714
|
+
last_dt = last_run_ts if last_run_ts.tzinfo else last_run_ts.replace(tzinfo=timezone.utc)
|
|
715
|
+
else:
|
|
716
|
+
last_dt = datetime.fromisoformat(str(last_run_ts).replace("Z", "+00:00"))
|
|
717
|
+
today = datetime.now(timezone.utc).date()
|
|
718
|
+
if last_dt.date() != today:
|
|
719
|
+
runs_today = 0 # Reset on new day
|
|
720
|
+
except (ValueError, TypeError, AttributeError):
|
|
721
|
+
runs_today = 0
|
|
722
|
+
|
|
723
|
+
if runs_today >= max_runs and not force:
|
|
724
|
+
warn(f"Budget: max_runs_per_day ({max_runs}) reached for today. Skipping.")
|
|
725
|
+
warn(" Re-run with: ./bin/loop run <loop> --force")
|
|
726
|
+
return 0
|
|
727
|
+
if force and runs_today >= max_runs:
|
|
728
|
+
warn(f"Budget gate bypassed via --force (runs_today={runs_today}, max={max_runs})")
|
|
729
|
+
|
|
730
|
+
# Create run directory
|
|
731
|
+
rid = run_id()
|
|
732
|
+
run_dir = loop_dir / "runs" / rid
|
|
733
|
+
run_dir.mkdir(parents=True)
|
|
734
|
+
|
|
735
|
+
log(f"Starting run {rid} for '{loop_name}' (tier={tier})")
|
|
736
|
+
log(f"Run artifacts: {run_dir.relative_to(WORKSPACE_ROOT)}")
|
|
737
|
+
allow = _as_str_list(meta.get("allowlist"))
|
|
738
|
+
deny = _as_str_list(meta.get("deny"))
|
|
739
|
+
log(
|
|
740
|
+
f"Autonomy: allow=[{', '.join(allow) or '∅'}] "
|
|
741
|
+
f"deny=[{', '.join(deny) or '∅'}] "
|
|
742
|
+
f"verifier={meta.get('verifier') or 'none'}"
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
# Write initial trace entry with AGENTS.md spec hash
|
|
746
|
+
agents_md = WORKSPACE_ROOT / "AGENTS.md"
|
|
747
|
+
agents_md_hash = hashlib.sha256(agents_md.read_bytes()).hexdigest()[:12] if agents_md.exists() else "?"
|
|
748
|
+
|
|
749
|
+
trace_file = run_dir / "trace.jsonl"
|
|
750
|
+
trace_file.write_text(
|
|
751
|
+
json.dumps({
|
|
752
|
+
"ts": utc_now(),
|
|
753
|
+
"kind": "run_start",
|
|
754
|
+
"run_id": rid,
|
|
755
|
+
"tier": tier,
|
|
756
|
+
"allowlist": allow,
|
|
757
|
+
"deny": deny,
|
|
758
|
+
"verifier": meta.get("verifier"),
|
|
759
|
+
"agents_md_hash": agents_md_hash,
|
|
760
|
+
"force": force,
|
|
761
|
+
}) + "\n",
|
|
762
|
+
encoding="utf-8",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
# Create git worktree (if in a git repo)
|
|
766
|
+
worktree_path: Path | None = None
|
|
767
|
+
if subprocess.run(
|
|
768
|
+
["git", "-C", str(WORKSPACE_ROOT), "rev-parse", "--git-dir"],
|
|
769
|
+
capture_output=True,
|
|
770
|
+
).returncode == 0:
|
|
771
|
+
worktree_base = WORKTREES_HOME / loop_name / rid
|
|
772
|
+
worktree_base.mkdir(parents=True, exist_ok=True)
|
|
773
|
+
result = subprocess.run(
|
|
774
|
+
["git", "-C", str(WORKSPACE_ROOT), "worktree", "add",
|
|
775
|
+
str(worktree_base), "HEAD"],
|
|
776
|
+
capture_output=True,
|
|
777
|
+
text=True,
|
|
778
|
+
)
|
|
779
|
+
if result.returncode == 0:
|
|
780
|
+
worktree_path = worktree_base
|
|
781
|
+
log(f"Worktree: {worktree_path}")
|
|
782
|
+
trace_file.write_text(
|
|
783
|
+
trace_file.read_text(encoding="utf-8")
|
|
784
|
+
+ json.dumps({"ts": utc_now(), "kind": "worktree_created",
|
|
785
|
+
"path": str(worktree_path)}) + "\n",
|
|
786
|
+
encoding="utf-8",
|
|
787
|
+
)
|
|
788
|
+
else:
|
|
789
|
+
warn(f"Could not create worktree (non-fatal): {result.stderr.strip()}")
|
|
790
|
+
|
|
791
|
+
# Check cancellation before dispatching
|
|
792
|
+
if _CANCELLED:
|
|
793
|
+
warn("Run cancelled before dispatch")
|
|
794
|
+
finalize_run(loop_dir, run_dir, rid, worktree_path, trace_file, "cancelled")
|
|
795
|
+
return 1
|
|
796
|
+
|
|
797
|
+
# Dispatch to runner (agent-toolkit or skeleton)
|
|
798
|
+
runner_dir = Path(
|
|
799
|
+
os.environ.get("HARNESS_RUNNER_DIR", "")
|
|
800
|
+
or Path.home() / ".local" / "share" / "agent-toolkit" / "runner"
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
plan_md = run_dir / "plan.md"
|
|
804
|
+
prompt = _build_runner_prompt(loop_dir, meta, loop_name, run_dir)
|
|
805
|
+
# Persist the exact prompt for audit / autonomy debugging
|
|
806
|
+
(run_dir / "prompt.md").write_text(prompt, encoding="utf-8")
|
|
807
|
+
|
|
808
|
+
runner_mod = None
|
|
809
|
+
if runner_dir.is_dir():
|
|
810
|
+
if str(runner_dir) not in sys.path:
|
|
811
|
+
sys.path.insert(0, str(runner_dir))
|
|
812
|
+
try:
|
|
813
|
+
import dots_ai_devcompanion_runner as r # type: ignore
|
|
814
|
+
runner_mod = r
|
|
815
|
+
except ImportError:
|
|
816
|
+
pass
|
|
817
|
+
|
|
818
|
+
# Dispatch: agent-toolkit → claude CLI → opencode → devcompanion queue → skeleton
|
|
819
|
+
# Install the hard gate before any runner that can invoke `gh`.
|
|
820
|
+
queued = False
|
|
821
|
+
try:
|
|
822
|
+
if runner_mod is not None:
|
|
823
|
+
_install_gate_into_environ(run_dir, meta)
|
|
824
|
+
try:
|
|
825
|
+
job_file = run_dir / "job.json"
|
|
826
|
+
job_file.write_text(
|
|
827
|
+
json.dumps({
|
|
828
|
+
"id": rid,
|
|
829
|
+
"created_at": utc_now(),
|
|
830
|
+
"request": prompt,
|
|
831
|
+
"loop_gate": {
|
|
832
|
+
"tier": str(meta.get("tier", "L1")),
|
|
833
|
+
"allowlist": _as_str_list(meta.get("allowlist")),
|
|
834
|
+
"deny": _as_str_list(meta.get("deny")),
|
|
835
|
+
"verifier": str(meta.get("verifier") or ""),
|
|
836
|
+
"run_dir": str(run_dir),
|
|
837
|
+
},
|
|
838
|
+
}),
|
|
839
|
+
encoding="utf-8",
|
|
840
|
+
)
|
|
841
|
+
runner_mod.main(["--job", str(job_file), "--out", str(run_dir)])
|
|
842
|
+
except Exception as e:
|
|
843
|
+
err(f"Runner error: {e}")
|
|
844
|
+
elif _try_claude_runner(prompt, run_dir, meta):
|
|
845
|
+
pass # claude runner handled it
|
|
846
|
+
elif _try_opencode_runner(prompt, run_dir, meta):
|
|
847
|
+
pass # opencode runner handled it
|
|
848
|
+
elif _queue_via_devcompanion(prompt, run_dir, loop_name, rid, meta):
|
|
849
|
+
queued = True # queued for async devcompanion processing
|
|
850
|
+
log("Loop dispatched to devcompanion queue — worker will process asynchronously.")
|
|
851
|
+
else:
|
|
852
|
+
warn("No runner found — writing skeleton plan.md")
|
|
853
|
+
plan_md.write_text(
|
|
854
|
+
f"# Loop Run: {loop_name}\n\n"
|
|
855
|
+
f"**Run ID**: {rid} \n"
|
|
856
|
+
f"**Tier**: {tier} \n"
|
|
857
|
+
f"**Started**: {utc_now()} \n\n"
|
|
858
|
+
"## Goal\n\n"
|
|
859
|
+
f"{meta.get('goal', '(no goal defined in LOOP.md)')}\n\n"
|
|
860
|
+
"## Status\n\n"
|
|
861
|
+
"⚠ No runner found. Options:\n"
|
|
862
|
+
" 1. Install Claude Code CLI (`claude` in PATH) for native execution.\n"
|
|
863
|
+
" 2. Install opencode CLI (`opencode` in PATH) for headless execution.\n"
|
|
864
|
+
" 3. Install agent-toolkit and set HARNESS_RUNNER_DIR.\n"
|
|
865
|
+
"See: https://github.com/ulises-jeremias/agent-toolkit\n",
|
|
866
|
+
encoding="utf-8",
|
|
867
|
+
)
|
|
868
|
+
except RuntimeError as gate_exc:
|
|
869
|
+
err(str(gate_exc))
|
|
870
|
+
finalize_run(loop_dir, run_dir, rid, worktree_path, trace_file, "gate_failed")
|
|
871
|
+
return 2
|
|
872
|
+
|
|
873
|
+
# Update state — preserve pending/escalations/notes the agent wrote (or prior)
|
|
874
|
+
run_status = "queued" if queued else "completed"
|
|
875
|
+
post = parse_state_md(loop_dir) if state_md.exists() else {}
|
|
876
|
+
new_state: dict[str, Any] = {
|
|
877
|
+
"last_run": utc_now(),
|
|
878
|
+
"last_run_status": run_status,
|
|
879
|
+
"last_run_id": rid,
|
|
880
|
+
"runs_today": runs_today + 1,
|
|
881
|
+
}
|
|
882
|
+
# Prefer agent-updated lists when present; otherwise keep prior
|
|
883
|
+
for key in ("pending", "escalations", "notes"):
|
|
884
|
+
if key in post and post[key] is not None:
|
|
885
|
+
new_state[key] = post[key]
|
|
886
|
+
elif key in state and state[key] is not None:
|
|
887
|
+
new_state[key] = state[key]
|
|
888
|
+
elif key in ("pending", "escalations"):
|
|
889
|
+
new_state[key] = []
|
|
890
|
+
if queued:
|
|
891
|
+
queued_pending = list(_as_str_list(new_state.get("pending")))
|
|
892
|
+
queued_pending.append(f"devcompanion:loop-{loop_name}-{rid}")
|
|
893
|
+
new_state["pending"] = queued_pending
|
|
894
|
+
write_state_md(loop_dir, new_state)
|
|
895
|
+
|
|
896
|
+
# For queued runs, add trace entry and clean up worktree
|
|
897
|
+
if queued:
|
|
898
|
+
trace_file.write_text(
|
|
899
|
+
trace_file.read_text(encoding="utf-8")
|
|
900
|
+
+ json.dumps({"ts": utc_now(), "kind": "queued",
|
|
901
|
+
"job_id": f"loop-{loop_name}-{rid}"}) + "\n",
|
|
902
|
+
encoding="utf-8",
|
|
903
|
+
)
|
|
904
|
+
# Clean up worktree — the devcompanion worker runs independently
|
|
905
|
+
if worktree_path and worktree_path.is_dir():
|
|
906
|
+
subprocess.run(
|
|
907
|
+
["git", "-C", str(WORKSPACE_ROOT), "worktree", "remove",
|
|
908
|
+
str(worktree_path), "--force"],
|
|
909
|
+
capture_output=True,
|
|
910
|
+
)
|
|
911
|
+
ok(f"Run {rid} queued for async processing. State: {run_status}")
|
|
912
|
+
ok(f" Check later with: devcompanion status")
|
|
913
|
+
return 0
|
|
914
|
+
|
|
915
|
+
# Cleanup worktree on inline completion
|
|
916
|
+
if worktree_path and worktree_path.is_dir():
|
|
917
|
+
subprocess.run(
|
|
918
|
+
["git", "-C", str(WORKSPACE_ROOT), "worktree", "remove",
|
|
919
|
+
str(worktree_path), "--force"],
|
|
920
|
+
capture_output=True,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
finalize_run(loop_dir, run_dir, rid, worktree_path, trace_file, "completed")
|
|
924
|
+
return 0
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def finalize_run(
|
|
928
|
+
loop_dir: Path,
|
|
929
|
+
run_dir: Path,
|
|
930
|
+
rid: str,
|
|
931
|
+
worktree_path: Path | None,
|
|
932
|
+
trace_file: Path,
|
|
933
|
+
status: str,
|
|
934
|
+
) -> None:
|
|
935
|
+
"""Clean up worktree, finalize trace, and close the loop."""
|
|
936
|
+
global _CANCELLED
|
|
937
|
+
if _CANCELLED and status != "cancelled":
|
|
938
|
+
status = "cancelled"
|
|
939
|
+
|
|
940
|
+
# Cleanup worktree
|
|
941
|
+
if worktree_path and worktree_path.is_dir():
|
|
942
|
+
subprocess.run(
|
|
943
|
+
["git", "-C", str(WORKSPACE_ROOT), "worktree", "remove",
|
|
944
|
+
str(worktree_path), "--force"],
|
|
945
|
+
capture_output=True,
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
# Finalize trace
|
|
949
|
+
trace_file.write_text(
|
|
950
|
+
trace_file.read_text(encoding="utf-8")
|
|
951
|
+
+ json.dumps({"ts": utc_now(), "kind": "run_end", "status": status}) + "\n",
|
|
952
|
+
encoding="utf-8",
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
if status == "completed":
|
|
956
|
+
# Loop closure: extract learning events from trace and sync to knowledge
|
|
957
|
+
_close_loop(run_dir, rid)
|
|
958
|
+
ok(f"Run {rid} complete. Artifacts at: {run_dir.relative_to(WORKSPACE_ROOT)}")
|
|
959
|
+
else:
|
|
960
|
+
warn(f"Run {rid} finished with status: {status}")
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _close_loop(run_dir: Path, rid: str) -> None:
|
|
964
|
+
"""Extract learning/decision events from trace and sync to knowledge."""
|
|
965
|
+
trace_file = run_dir / "trace.jsonl"
|
|
966
|
+
if not trace_file.exists():
|
|
967
|
+
return
|
|
968
|
+
|
|
969
|
+
added = 0
|
|
970
|
+
for line in trace_file.read_text(encoding="utf-8").splitlines():
|
|
971
|
+
try:
|
|
972
|
+
event = json.loads(line)
|
|
973
|
+
except json.JSONDecodeError:
|
|
974
|
+
continue
|
|
975
|
+
if event.get("kind") == "decision" and event.get("tag") == "learning":
|
|
976
|
+
content = event.get("content", "")
|
|
977
|
+
if content:
|
|
978
|
+
add_script = WORKSPACE_ROOT / "bin" / "assistant-memory"
|
|
979
|
+
if add_script.exists():
|
|
980
|
+
subprocess.run(
|
|
981
|
+
[sys.executable, str(add_script), "add",
|
|
982
|
+
"--type", "learning",
|
|
983
|
+
"--from-loop", rid,
|
|
984
|
+
content],
|
|
985
|
+
capture_output=True,
|
|
986
|
+
)
|
|
987
|
+
added += 1
|
|
988
|
+
if event.get("kind") == "run_start":
|
|
989
|
+
loop_name = event.get("run_id", rid)
|
|
990
|
+
|
|
991
|
+
if added:
|
|
992
|
+
ok(f"Synced {added} learning event(s) from run {rid} to knowledge base")
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def cmd_status(args: list[str]) -> int:
|
|
996
|
+
"""Show loop status."""
|
|
997
|
+
target = args[0] if args else None
|
|
998
|
+
loops = [LOOPS_DIR / target] if target else list_loops()
|
|
999
|
+
|
|
1000
|
+
if not loops:
|
|
1001
|
+
print("No loops found. Run: ./bin/loop init <pattern>")
|
|
1002
|
+
return 0
|
|
1003
|
+
|
|
1004
|
+
print("")
|
|
1005
|
+
print(blue("── Loop Status ──────────────────────────────────────────"))
|
|
1006
|
+
for loop_dir in loops:
|
|
1007
|
+
if not loop_dir.exists():
|
|
1008
|
+
err(f"Loop '{loop_dir.name}' not found.")
|
|
1009
|
+
continue
|
|
1010
|
+
meta = parse_loop_md(loop_dir)
|
|
1011
|
+
tier = meta.get("tier", "?")
|
|
1012
|
+
cadence = meta.get("cadence", "?")
|
|
1013
|
+
runs = list(sorted((loop_dir / "runs").glob("*"))) if (loop_dir / "runs").is_dir() else []
|
|
1014
|
+
state_file = loop_dir / "STATE.md"
|
|
1015
|
+
last_status = "not_run"
|
|
1016
|
+
if state_file.exists():
|
|
1017
|
+
# Read STATE.md directly (parse_loop_md would read LOOP.md)
|
|
1018
|
+
state_text = state_file.read_text(encoding="utf-8")
|
|
1019
|
+
lines = state_text.splitlines()
|
|
1020
|
+
# Extract frontmatter
|
|
1021
|
+
if lines and lines[0].strip() == "---":
|
|
1022
|
+
end = next((i for i, l in enumerate(lines[1:], 1) if l.strip() == "---"), None)
|
|
1023
|
+
if end:
|
|
1024
|
+
for l in lines[1:end]:
|
|
1025
|
+
if l.strip().startswith("last_run_status:"):
|
|
1026
|
+
last_status = l.split(":", 1)[1].strip()
|
|
1027
|
+
break
|
|
1028
|
+
print(
|
|
1029
|
+
f" {dim(loop_dir.name):<35} tier={tier} cadence={cadence} "
|
|
1030
|
+
f"runs={len(runs)} last={last_status}"
|
|
1031
|
+
)
|
|
1032
|
+
print("")
|
|
1033
|
+
return 0
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def cmd_audit(args: list[str]) -> int:
|
|
1037
|
+
"""Summarize past runs."""
|
|
1038
|
+
target = args[0] if args else None
|
|
1039
|
+
loops = [LOOPS_DIR / target] if target else list_loops()
|
|
1040
|
+
|
|
1041
|
+
print("")
|
|
1042
|
+
print(blue("── Loop Audit ───────────────────────────────────────────"))
|
|
1043
|
+
for loop_dir in loops:
|
|
1044
|
+
if not loop_dir.exists():
|
|
1045
|
+
continue
|
|
1046
|
+
runs_dir = loop_dir / "runs"
|
|
1047
|
+
if not runs_dir.is_dir():
|
|
1048
|
+
continue
|
|
1049
|
+
runs = sorted(runs_dir.iterdir())
|
|
1050
|
+
if not runs:
|
|
1051
|
+
print(f"\n {loop_dir.name}: no runs yet")
|
|
1052
|
+
continue
|
|
1053
|
+
|
|
1054
|
+
completed = 0
|
|
1055
|
+
failed = 0
|
|
1056
|
+
total_tokens = 0
|
|
1057
|
+
|
|
1058
|
+
for run in runs:
|
|
1059
|
+
trace = run / "trace.jsonl"
|
|
1060
|
+
if not trace.exists():
|
|
1061
|
+
continue
|
|
1062
|
+
for line in trace.read_text(encoding="utf-8").splitlines():
|
|
1063
|
+
try:
|
|
1064
|
+
event = json.loads(line)
|
|
1065
|
+
if event.get("kind") == "run_end":
|
|
1066
|
+
if event.get("status") == "completed":
|
|
1067
|
+
completed += 1
|
|
1068
|
+
else:
|
|
1069
|
+
failed += 1
|
|
1070
|
+
if event.get("kind") in ("prompt", "completion"):
|
|
1071
|
+
total_tokens += event.get("prompt_tokens", 0)
|
|
1072
|
+
total_tokens += event.get("completion_tokens", 0)
|
|
1073
|
+
except json.JSONDecodeError:
|
|
1074
|
+
pass
|
|
1075
|
+
|
|
1076
|
+
total = completed + failed
|
|
1077
|
+
rate = f"{completed/total*100:.0f}%" if total else "—"
|
|
1078
|
+
print(
|
|
1079
|
+
f"\n {dim(loop_dir.name)}\n"
|
|
1080
|
+
f" runs={total} success={completed} failed={failed} "
|
|
1081
|
+
f"rate={rate} tokens≈{total_tokens:,}"
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
print("")
|
|
1085
|
+
return 0
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def cmd_cost(args: list[str]) -> int:
|
|
1089
|
+
"""Estimate cost for one run."""
|
|
1090
|
+
if not args:
|
|
1091
|
+
err("Usage: loop cost <loop-name>")
|
|
1092
|
+
return 1
|
|
1093
|
+
|
|
1094
|
+
loop_name = args[0]
|
|
1095
|
+
loop_dir = LOOPS_DIR / loop_name
|
|
1096
|
+
meta = parse_loop_md(loop_dir) if loop_dir.exists() else {}
|
|
1097
|
+
|
|
1098
|
+
tier = meta.get("tier", "L1")
|
|
1099
|
+
budget = meta.get("budget", {})
|
|
1100
|
+
max_tokens = budget.get("max_tokens", "?")
|
|
1101
|
+
cadence = meta.get("cadence", "?")
|
|
1102
|
+
|
|
1103
|
+
cost_tiers = {
|
|
1104
|
+
"daily-triage": ("low", "~$0.01-0.05"),
|
|
1105
|
+
"issue-triage": ("low", "~$0.01-0.05"),
|
|
1106
|
+
"changelog-drafter": ("low", "~$0.01-0.05"),
|
|
1107
|
+
"post-merge-cleanup": ("low", "~$0.01-0.05"),
|
|
1108
|
+
"dep-sweeper": ("medium", "~$0.05-0.20"),
|
|
1109
|
+
"pr-babysitter": ("high", "~$0.20-1.00/run"),
|
|
1110
|
+
"ci-sweeper": ("very-high", "~$0.50-2.00/run"),
|
|
1111
|
+
}
|
|
1112
|
+
tier_label, est = cost_tiers.get(loop_name, ("unknown", "—"))
|
|
1113
|
+
|
|
1114
|
+
print(f"\n Loop: {loop_name}")
|
|
1115
|
+
print(f" Tier: {tier}")
|
|
1116
|
+
print(f" Cadence: {cadence}")
|
|
1117
|
+
print(f" Budget max_tokens: {max_tokens}")
|
|
1118
|
+
print(f" Cost tier: {tier_label}")
|
|
1119
|
+
print(f" Estimated per run: {est}")
|
|
1120
|
+
print("")
|
|
1121
|
+
return 0
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def cmd_schedule(args: list[str]) -> int:
|
|
1125
|
+
"""Install or manage OS-level timer for a loop."""
|
|
1126
|
+
list_mode = "--list" in args
|
|
1127
|
+
remove_mode = "--remove" in args
|
|
1128
|
+
status_mode = "--status" in args
|
|
1129
|
+
dry_run = "--dry-run" in args
|
|
1130
|
+
cron_override = None
|
|
1131
|
+
|
|
1132
|
+
# Extract --cron value
|
|
1133
|
+
for i, a in enumerate(args):
|
|
1134
|
+
if a == "--cron" and i + 1 < len(args):
|
|
1135
|
+
cron_override = args[i + 1]
|
|
1136
|
+
break
|
|
1137
|
+
if a.startswith("--cron="):
|
|
1138
|
+
cron_override = a.split("=", 1)[1]
|
|
1139
|
+
break
|
|
1140
|
+
|
|
1141
|
+
# --list: show all scheduled loops
|
|
1142
|
+
if list_mode:
|
|
1143
|
+
return _schedule_list()
|
|
1144
|
+
if status_mode:
|
|
1145
|
+
return _schedule_status()
|
|
1146
|
+
|
|
1147
|
+
# --remove: remove schedule for a loop
|
|
1148
|
+
if remove_mode:
|
|
1149
|
+
loop_name = next((a for a in args if not a.startswith("-") and a != "schedule"), None)
|
|
1150
|
+
if not loop_name:
|
|
1151
|
+
err("Usage: bin/loop schedule <name> --remove")
|
|
1152
|
+
return 1
|
|
1153
|
+
return _schedule_remove(loop_name, dry_run)
|
|
1154
|
+
|
|
1155
|
+
# schedule <name>: install timer
|
|
1156
|
+
loop_name = next((a for a in args if not a.startswith("-") and a != "schedule"), None)
|
|
1157
|
+
if not loop_name:
|
|
1158
|
+
err("Usage: bin/loop schedule <name> [--cron EXPR] [--dry-run]")
|
|
1159
|
+
return 1
|
|
1160
|
+
|
|
1161
|
+
loop_dir = LOOPS_DIR / loop_name
|
|
1162
|
+
if not loop_dir.is_dir():
|
|
1163
|
+
err(f"Loop not found: {loop_name} (run 'bin/loop init {loop_name}' first)")
|
|
1164
|
+
return 1
|
|
1165
|
+
|
|
1166
|
+
loopyaml = loop_dir / "LOOP.md"
|
|
1167
|
+
if not loopyaml.exists():
|
|
1168
|
+
err(f"LOOP.md not found in {loop_dir}")
|
|
1169
|
+
return 1
|
|
1170
|
+
|
|
1171
|
+
loop_cfg = parse_loop_md(loopyaml)
|
|
1172
|
+
cron = cron_override or loop_cfg.get("schedule", "daily")
|
|
1173
|
+
|
|
1174
|
+
if dry_run:
|
|
1175
|
+
log(f"[DRY-RUN] Would schedule: {loop_name}")
|
|
1176
|
+
log(f" Cron: {cron}")
|
|
1177
|
+
return _schedule_dry_run(loop_name, cron)
|
|
1178
|
+
|
|
1179
|
+
return _schedule_install(loop_name, cron)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _schedule_dry_run(loop_name: str, cron: str) -> int:
|
|
1183
|
+
"""Print timer files without installing."""
|
|
1184
|
+
system = platform.system()
|
|
1185
|
+
harness_dir = WORKSPACE_ROOT
|
|
1186
|
+
loop_cmd = f"{harness_dir}/bin/loop run {loop_name}"
|
|
1187
|
+
|
|
1188
|
+
print(f"\n{blue('[loop]')} Would schedule: {loop_name}")
|
|
1189
|
+
print(f" Cron: {cron}")
|
|
1190
|
+
print(f" Command: {loop_cmd}")
|
|
1191
|
+
print()
|
|
1192
|
+
|
|
1193
|
+
if system == "Linux":
|
|
1194
|
+
svc = _systemd_service(loop_name, loop_cmd)
|
|
1195
|
+
timer = _systemd_timer(loop_name, cron)
|
|
1196
|
+
print(dim("--- systemd service (would create) ---"))
|
|
1197
|
+
print(svc)
|
|
1198
|
+
print(dim("--- systemd timer (would create) ---"))
|
|
1199
|
+
print(timer)
|
|
1200
|
+
print(dim(f"Would enable: systemctl --user enable agent-toolkit-{loop_name}.timer"))
|
|
1201
|
+
print(dim(f"Would start: systemctl --user start agent-toolkit-{loop_name}.timer"))
|
|
1202
|
+
elif system == "Darwin":
|
|
1203
|
+
plist = _launchd_plist(loop_name, cron)
|
|
1204
|
+
print(dim("--- launchd plist (would create) ---"))
|
|
1205
|
+
print(plist)
|
|
1206
|
+
print(dim(f"Would load: launchctl load ~/Library/LaunchAgents/com.agent-toolkit.{loop_name}.plist"))
|
|
1207
|
+
else:
|
|
1208
|
+
print(yellow(f"Manual scheduling required on {system}:"))
|
|
1209
|
+
print(f" {loop_cmd}")
|
|
1210
|
+
print(f" Schedule: {cron}")
|
|
1211
|
+
print()
|
|
1212
|
+
|
|
1213
|
+
return 0
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _schedule_install(loop_name: str, cron: str) -> int:
|
|
1217
|
+
"""Install systemd timer (Linux) or launchd plist (macOS)."""
|
|
1218
|
+
system = platform.system()
|
|
1219
|
+
harness_dir = WORKSPACE_ROOT
|
|
1220
|
+
loop_cmd = f"{harness_dir}/bin/loop run {loop_name}"
|
|
1221
|
+
|
|
1222
|
+
if system == "Linux":
|
|
1223
|
+
systemd_user = Path.home() / ".config" / "systemd" / "user"
|
|
1224
|
+
systemd_user.mkdir(parents=True, exist_ok=True)
|
|
1225
|
+
|
|
1226
|
+
svc_path = systemd_user / f"agent-toolkit-{loop_name}.service"
|
|
1227
|
+
timer_path = systemd_user / f"agent-toolkit-{loop_name}.timer"
|
|
1228
|
+
|
|
1229
|
+
svc_path.write_text(_systemd_service(loop_name, loop_cmd), encoding="utf-8")
|
|
1230
|
+
timer_path.write_text(_systemd_timer(loop_name, cron), encoding="utf-8")
|
|
1231
|
+
|
|
1232
|
+
ok(f"Created: {svc_path}")
|
|
1233
|
+
ok(f"Created: {timer_path}")
|
|
1234
|
+
|
|
1235
|
+
# Enable and start
|
|
1236
|
+
try:
|
|
1237
|
+
subprocess.run(
|
|
1238
|
+
["systemctl", "--user", "enable", f"agent-toolkit-{loop_name}.timer"],
|
|
1239
|
+
capture_output=True, text=True, check=False,
|
|
1240
|
+
)
|
|
1241
|
+
subprocess.run(
|
|
1242
|
+
["systemctl", "--user", "start", f"agent-toolkit-{loop_name}.timer"],
|
|
1243
|
+
capture_output=True, text=True, check=False,
|
|
1244
|
+
)
|
|
1245
|
+
ok(f"Enabled and started: agent-toolkit-{loop_name}.timer")
|
|
1246
|
+
except Exception as e:
|
|
1247
|
+
warn(f"Could not enable timer: {e}")
|
|
1248
|
+
ok("Timer files created manually — enable with: systemctl --user enable agent-toolkit-{loop_name}.timer")
|
|
1249
|
+
|
|
1250
|
+
return 0
|
|
1251
|
+
|
|
1252
|
+
elif system == "Darwin":
|
|
1253
|
+
launchd_dir = Path.home() / "Library" / "LaunchAgents"
|
|
1254
|
+
launchd_dir.mkdir(parents=True, exist_ok=True)
|
|
1255
|
+
|
|
1256
|
+
plist_path = launchd_dir / f"com.agent-toolkit.{loop_name}.plist"
|
|
1257
|
+
plist_path.write_text(_launchd_plist(loop_name, cron), encoding="utf-8")
|
|
1258
|
+
|
|
1259
|
+
ok(f"Created: {plist_path}")
|
|
1260
|
+
|
|
1261
|
+
try:
|
|
1262
|
+
subprocess.run(
|
|
1263
|
+
["launchctl", "load", str(plist_path)],
|
|
1264
|
+
capture_output=True, text=True, check=False,
|
|
1265
|
+
)
|
|
1266
|
+
ok(f"Loaded: com.agent-toolkit.{loop_name}")
|
|
1267
|
+
except Exception as e:
|
|
1268
|
+
warn(f"Could not load plist: {e}")
|
|
1269
|
+
ok(f"Plist created — load with: launchctl load ~/Library/LaunchAgents/com.agent-toolkit.{loop_name}.plist")
|
|
1270
|
+
|
|
1271
|
+
return 0
|
|
1272
|
+
|
|
1273
|
+
else:
|
|
1274
|
+
print(f"\n{yellow('Manual scheduling required on ' + system + ':')}")
|
|
1275
|
+
print(f" Command: {loop_cmd}")
|
|
1276
|
+
print(f" Schedule: {cron}")
|
|
1277
|
+
print()
|
|
1278
|
+
return 0
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _schedule_list() -> int:
|
|
1282
|
+
"""List all scheduled loops."""
|
|
1283
|
+
system = platform.system()
|
|
1284
|
+
loops = []
|
|
1285
|
+
if system == "Linux":
|
|
1286
|
+
systemd_user = Path.home() / ".config" / "systemd" / "user"
|
|
1287
|
+
for p in sorted(systemd_user.glob("agent-toolkit-*.timer")):
|
|
1288
|
+
name = p.stem.replace("agent-toolkit-", "").replace(".timer", "")
|
|
1289
|
+
loops.append(name)
|
|
1290
|
+
elif system == "Darwin":
|
|
1291
|
+
launchd_dir = Path.home() / "Library" / "LaunchAgents"
|
|
1292
|
+
for p in sorted(launchd_dir.glob("com.agent-toolkit.*.plist")):
|
|
1293
|
+
name = p.stem.replace("com.agent-toolkit.", "")
|
|
1294
|
+
loops.append(name)
|
|
1295
|
+
|
|
1296
|
+
if not loops:
|
|
1297
|
+
print(dim("No scheduled loops found."))
|
|
1298
|
+
return 0
|
|
1299
|
+
|
|
1300
|
+
print(f"\n{blue('[loop]')} Scheduled loops:")
|
|
1301
|
+
for name in loops:
|
|
1302
|
+
enabled = _schedule_enabled(name)
|
|
1303
|
+
enabled_str = green("enabled") if enabled else yellow("disabled")
|
|
1304
|
+
print(f" {name:30s} {enabled_str}")
|
|
1305
|
+
print()
|
|
1306
|
+
return 0
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _schedule_status() -> int:
|
|
1310
|
+
"""Check schedule health."""
|
|
1311
|
+
system = platform.system()
|
|
1312
|
+
issues = []
|
|
1313
|
+
if system == "Linux":
|
|
1314
|
+
systemd_user = Path.home() / ".config" / "systemd" / "user"
|
|
1315
|
+
for p in sorted(systemd_user.glob("agent-toolkit-*.timer")):
|
|
1316
|
+
name = p.stem.replace("agent-toolkit-", "").replace(".timer", "")
|
|
1317
|
+
# Check if timer is active
|
|
1318
|
+
result = subprocess.run(
|
|
1319
|
+
["systemctl", "--user", "is-active", f"agent-toolkit-{name}.timer"],
|
|
1320
|
+
capture_output=True, text=True, check=False,
|
|
1321
|
+
)
|
|
1322
|
+
status = result.stdout.strip()
|
|
1323
|
+
if status != "active":
|
|
1324
|
+
issues.append(f"{name}: {status}")
|
|
1325
|
+
elif system == "Darwin":
|
|
1326
|
+
launchd_dir = Path.home() / "Library" / "LaunchAgents"
|
|
1327
|
+
for p in sorted(launchd_dir.glob("com.agent-toolkit.*.plist")):
|
|
1328
|
+
name = p.stem.replace("com.agent-toolkit.", "")
|
|
1329
|
+
result = subprocess.run(
|
|
1330
|
+
["launchctl", "list", f"com.agent-toolkit.{name}"],
|
|
1331
|
+
capture_output=True, text=True, check=False,
|
|
1332
|
+
)
|
|
1333
|
+
if result.returncode != 0:
|
|
1334
|
+
issues.append(f"{name}: not loaded")
|
|
1335
|
+
|
|
1336
|
+
if issues:
|
|
1337
|
+
print(f"\n{yellow('[loop]')} Schedule issues:")
|
|
1338
|
+
for issue in issues:
|
|
1339
|
+
print(f" {yellow('!')} {issue}")
|
|
1340
|
+
print()
|
|
1341
|
+
else:
|
|
1342
|
+
ok("All schedules healthy.")
|
|
1343
|
+
return 0
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _schedule_remove(loop_name: str, dry_run: bool) -> int:
|
|
1347
|
+
"""Remove schedule for a loop."""
|
|
1348
|
+
system = platform.system()
|
|
1349
|
+
if system == "Linux":
|
|
1350
|
+
systemd_user = Path.home() / ".config" / "systemd" / "user"
|
|
1351
|
+
svc = systemd_user / f"agent-toolkit-{loop_name}.service"
|
|
1352
|
+
timer = systemd_user / f"agent-toolkit-{loop_name}.timer"
|
|
1353
|
+
|
|
1354
|
+
if dry_run:
|
|
1355
|
+
log(f"[DRY-RUN] Would remove: {svc}")
|
|
1356
|
+
log(f"[DRY-RUN] Would remove: {timer}")
|
|
1357
|
+
return 0
|
|
1358
|
+
|
|
1359
|
+
# Stop and disable first
|
|
1360
|
+
subprocess.run(
|
|
1361
|
+
["systemctl", "--user", "stop", f"agent-toolkit-{loop_name}.timer"],
|
|
1362
|
+
capture_output=True, text=True, check=False,
|
|
1363
|
+
)
|
|
1364
|
+
subprocess.run(
|
|
1365
|
+
["systemctl", "--user", "disable", f"agent-toolkit-{loop_name}.timer"],
|
|
1366
|
+
capture_output=True, text=True, check=False,
|
|
1367
|
+
)
|
|
1368
|
+
svc.unlink(missing_ok=True)
|
|
1369
|
+
timer.unlink(missing_ok=True)
|
|
1370
|
+
ok(f"Removed schedule: {loop_name}")
|
|
1371
|
+
|
|
1372
|
+
elif system == "Darwin":
|
|
1373
|
+
plist = Path.home() / "Library" / "LaunchAgents" / f"com.agent-toolkit.{loop_name}.plist"
|
|
1374
|
+
if dry_run:
|
|
1375
|
+
log(f"[DRY-RUN] Would remove: {plist}")
|
|
1376
|
+
return 0
|
|
1377
|
+
|
|
1378
|
+
subprocess.run(
|
|
1379
|
+
["launchctl", "unload", str(plist)],
|
|
1380
|
+
capture_output=True, text=True, check=False,
|
|
1381
|
+
)
|
|
1382
|
+
plist.unlink(missing_ok=True)
|
|
1383
|
+
ok(f"Removed schedule: {loop_name}")
|
|
1384
|
+
|
|
1385
|
+
else:
|
|
1386
|
+
warn(f"Manual removal required on {system}.")
|
|
1387
|
+
|
|
1388
|
+
return 0
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _schedule_enabled(loop_name: str) -> bool:
|
|
1392
|
+
"""Check if a loop timer is enabled."""
|
|
1393
|
+
system = platform.system()
|
|
1394
|
+
if system == "Linux":
|
|
1395
|
+
result = subprocess.run(
|
|
1396
|
+
["systemctl", "--user", "is-enabled", f"agent-toolkit-{loop_name}.timer"],
|
|
1397
|
+
capture_output=True, text=True, check=False,
|
|
1398
|
+
)
|
|
1399
|
+
return result.stdout.strip() == "enabled"
|
|
1400
|
+
elif system == "Darwin":
|
|
1401
|
+
plist = Path.home() / "Library" / "LaunchAgents" / f"com.agent-toolkit.{loop_name}.plist"
|
|
1402
|
+
if not plist.exists():
|
|
1403
|
+
return False
|
|
1404
|
+
result = subprocess.run(
|
|
1405
|
+
["launchctl", "list", f"com.agent-toolkit.{loop_name}"],
|
|
1406
|
+
capture_output=True, text=True, check=False,
|
|
1407
|
+
)
|
|
1408
|
+
return result.returncode == 0
|
|
1409
|
+
return False
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _systemd_service(loop_name: str, exec_cmd: str) -> str:
|
|
1413
|
+
"""Generate systemd service file content."""
|
|
1414
|
+
return f"""[Unit]
|
|
1415
|
+
Description=agent-toolkit loop: {loop_name}
|
|
1416
|
+
After=network-online.target
|
|
1417
|
+
|
|
1418
|
+
[Service]
|
|
1419
|
+
Type=oneshot
|
|
1420
|
+
ExecStart={exec_cmd}
|
|
1421
|
+
StandardOutput=journal
|
|
1422
|
+
StandardError=journal
|
|
1423
|
+
"""
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def _systemd_timer(loop_name: str, cron: str) -> str:
|
|
1427
|
+
"""Generate systemd timer file content."""
|
|
1428
|
+
on_calendar = cron
|
|
1429
|
+
# Convert common patterns to systemd OnCalendar format
|
|
1430
|
+
if cron == "hourly":
|
|
1431
|
+
on_calendar = "*-*-* *:00:00"
|
|
1432
|
+
elif cron == "daily":
|
|
1433
|
+
on_calendar = "*-*-* 09:00:00"
|
|
1434
|
+
elif cron == "weekly":
|
|
1435
|
+
on_calendar = "Mon *-*-* 09:00:00"
|
|
1436
|
+
|
|
1437
|
+
return f"""[Unit]
|
|
1438
|
+
Description=agent-toolkit loop timer: {loop_name}
|
|
1439
|
+
|
|
1440
|
+
[Timer]
|
|
1441
|
+
OnCalendar={on_calendar}
|
|
1442
|
+
Persistent=true
|
|
1443
|
+
|
|
1444
|
+
[Install]
|
|
1445
|
+
WantedBy=timers.target
|
|
1446
|
+
"""
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _launchd_plist(loop_name: str, cron: str) -> str:
|
|
1450
|
+
"""Generate launchd plist content."""
|
|
1451
|
+
interval = 86400 # daily default
|
|
1452
|
+
if cron == "hourly":
|
|
1453
|
+
interval = 3600
|
|
1454
|
+
elif cron == "weekly":
|
|
1455
|
+
interval = 604800
|
|
1456
|
+
|
|
1457
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
1458
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1459
|
+
<plist version="1.0">
|
|
1460
|
+
<dict>
|
|
1461
|
+
<key>Label</key>
|
|
1462
|
+
<string>com.agent-toolkit.{loop_name}</string>
|
|
1463
|
+
<key>ProgramArguments</key>
|
|
1464
|
+
<array>
|
|
1465
|
+
<string>{WORKSPACE_ROOT}/bin/loop</string>
|
|
1466
|
+
<string>run</string>
|
|
1467
|
+
<string>{loop_name}</string>
|
|
1468
|
+
</array>
|
|
1469
|
+
<key>StartInterval</key>
|
|
1470
|
+
<integer>{interval}</integer>
|
|
1471
|
+
<key>RunAtLoad</key>
|
|
1472
|
+
<false/>
|
|
1473
|
+
</dict>
|
|
1474
|
+
</plist>
|
|
1475
|
+
"""
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def cmd_sync() -> int:
|
|
1479
|
+
"""Regenerate knowledge todos from completed loop runs."""
|
|
1480
|
+
knowledge_todos = WORKSPACE_ROOT / "knowledge" / "todos" / "pending.md"
|
|
1481
|
+
if not LOOPS_DIR.is_dir():
|
|
1482
|
+
return 0
|
|
1483
|
+
|
|
1484
|
+
entries = []
|
|
1485
|
+
for loop_dir in sorted(LOOPS_DIR.iterdir()):
|
|
1486
|
+
if not (loop_dir / "STATE.md").exists():
|
|
1487
|
+
continue
|
|
1488
|
+
state = parse_state_md(loop_dir)
|
|
1489
|
+
escalations = state.get("escalations", []) or []
|
|
1490
|
+
for esc in escalations:
|
|
1491
|
+
entries.append(f"- [ ] [loop-escalation] {loop_dir.name}: {esc}")
|
|
1492
|
+
|
|
1493
|
+
if entries and knowledge_todos.exists():
|
|
1494
|
+
existing = knowledge_todos.read_text(encoding="utf-8")
|
|
1495
|
+
header = "<!-- loop-escalations -->"
|
|
1496
|
+
block = "\n".join(entries) + "\n"
|
|
1497
|
+
if header in existing:
|
|
1498
|
+
existing = re.sub(
|
|
1499
|
+
r"<!-- loop-escalations -->.*?<!-- /loop-escalations -->",
|
|
1500
|
+
f"{header}\n{block}<!-- /loop-escalations -->",
|
|
1501
|
+
existing,
|
|
1502
|
+
flags=re.DOTALL,
|
|
1503
|
+
)
|
|
1504
|
+
else:
|
|
1505
|
+
existing += f"\n{header}\n{block}<!-- /loop-escalations -->\n"
|
|
1506
|
+
knowledge_todos.write_text(existing, encoding="utf-8")
|
|
1507
|
+
ok(f"Synced {len(entries)} escalation(s) to {knowledge_todos.relative_to(WORKSPACE_ROOT)}")
|
|
1508
|
+
|
|
1509
|
+
return 0
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
# ── main ──────────────────────────────────────────────────────────────────────
|
|
1513
|
+
|
|
1514
|
+
def main() -> None:
|
|
1515
|
+
argv = sys.argv[1:]
|
|
1516
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
1517
|
+
print(__doc__)
|
|
1518
|
+
sys.exit(0)
|
|
1519
|
+
|
|
1520
|
+
command = argv[0]
|
|
1521
|
+
rest = argv[1:]
|
|
1522
|
+
|
|
1523
|
+
match command:
|
|
1524
|
+
case "init":
|
|
1525
|
+
sys.exit(cmd_init(rest))
|
|
1526
|
+
case "run":
|
|
1527
|
+
sys.exit(cmd_run(rest))
|
|
1528
|
+
case "status":
|
|
1529
|
+
sys.exit(cmd_status(rest))
|
|
1530
|
+
case "audit":
|
|
1531
|
+
sys.exit(cmd_audit(rest))
|
|
1532
|
+
case "cost":
|
|
1533
|
+
sys.exit(cmd_cost(rest))
|
|
1534
|
+
case "schedule":
|
|
1535
|
+
sys.exit(cmd_schedule(rest))
|
|
1536
|
+
case "sync":
|
|
1537
|
+
sys.exit(cmd_sync())
|
|
1538
|
+
case "templates":
|
|
1539
|
+
for t in list_templates():
|
|
1540
|
+
print(f" {t.stem}")
|
|
1541
|
+
case _:
|
|
1542
|
+
err(f"Unknown command: {command}")
|
|
1543
|
+
print("Run 'bin/loop help' for usage.")
|
|
1544
|
+
sys.exit(1)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
if __name__ == "__main__":
|
|
1548
|
+
main()
|