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.
Files changed (310) hide show
  1. agent_toolkit/__init__.py +4 -0
  2. agent_toolkit/__main__.py +3 -0
  3. agent_toolkit/_paths.py +64 -0
  4. agent_toolkit/cli/__init__.py +0 -0
  5. agent_toolkit/cli/doctor.py +453 -0
  6. agent_toolkit/cli/install.py +445 -0
  7. agent_toolkit/cli/main.py +66 -0
  8. agent_toolkit/cli/mcp.py +418 -0
  9. agent_toolkit/cli/plugin.py +213 -0
  10. agent_toolkit/cli/skills.py +421 -0
  11. agent_toolkit/loop/__init__.py +0 -0
  12. agent_toolkit/loop/gh_gate.py +626 -0
  13. agent_toolkit/loop/runner.py +1548 -0
  14. agent_toolkit/runner/__init__.py +0 -0
  15. agent_toolkit/runner/dispatcher.py +164 -0
  16. agent_toolkit/runner/policy.py +345 -0
  17. agent_toolkit/runner/providers/__init__.py +21 -0
  18. agent_toolkit/runner/providers/anthropic_provider.py +109 -0
  19. agent_toolkit/runner/providers/base.py +53 -0
  20. agent_toolkit/runner/providers/ollama_provider.py +108 -0
  21. agent_toolkit/runner/providers/openai_provider.py +105 -0
  22. agent_toolkit/runner/providers/opencode_provider.py +127 -0
  23. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/architect/AGENT.md +30 -0
  24. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/assistant/AGENT.md +59 -0
  25. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/build-error-resolver/AGENT.md +30 -0
  26. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/client-workflow-bootstrap/AGENT.md +60 -0
  27. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/code-reviewer/AGENT.md +49 -0
  28. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/database-reviewer/AGENT.md +44 -0
  29. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/docs-lookup/AGENT.md +28 -0
  30. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/e2e-runner/AGENT.md +38 -0
  31. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/performance-optimizer/AGENT.md +52 -0
  32. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/planner/AGENT.md +41 -0
  33. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/refactor-cleaner/AGENT.md +43 -0
  34. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/reference-lookup/AGENT.md +33 -0
  35. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/security-reviewer/AGENT.md +47 -0
  36. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/tdd-guide/AGENT.md +58 -0
  37. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/tech-assistant/AGENT.md +11 -0
  38. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/agents/typescript-reviewer/AGENT.md +61 -0
  39. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/catalogs/agent-catalog.yaml +202 -0
  40. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/catalogs/loop-catalog.yaml +165 -0
  41. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/catalogs/skill-catalog.yaml +699 -0
  42. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/catalogs/skills-layout.json +337 -0
  43. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/changelog-drafter/loop.yaml +40 -0
  44. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/ci-sweeper/loop.yaml +53 -0
  45. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/daily-triage/loop.yaml +47 -0
  46. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/dep-sweeper/loop.yaml +46 -0
  47. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/issue-triage/loop.yaml +40 -0
  48. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/oss-daily-briefing/loop.yaml +65 -0
  49. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/oss-pr-monitor/loop.yaml +85 -0
  50. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/oss-triage/loop.yaml +67 -0
  51. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/post-merge-cleanup/loop.yaml +44 -0
  52. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/loops/pr-babysitter/loop.yaml +46 -0
  53. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/clickup/README.md +45 -0
  54. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/clickup/config.template.json +7 -0
  55. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/clickup/wrapper.sh +23 -0
  56. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/figma/README.md +56 -0
  57. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/figma/config.template.json +9 -0
  58. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/github/README.md +11 -0
  59. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/github/config.template.json +7 -0
  60. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/github/wrapper.sh +23 -0
  61. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/linear/README.md +49 -0
  62. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/linear/config.template.json +10 -0
  63. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/notion/README.md +7 -0
  64. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/notion/config.template.json +7 -0
  65. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/notion/wrapper.sh +23 -0
  66. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/slack/README.md +8 -0
  67. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/slack/config.template.json +8 -0
  68. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/mcp/templates/slack/wrapper.sh +23 -0
  69. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/CLAUDE.md +66 -0
  70. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/assistant.md +59 -0
  71. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/build-error-resolver.md +30 -0
  72. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/client-workflow-bootstrap.md +60 -0
  73. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/contribution-planner.md +39 -0
  74. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/database-reviewer.md +44 -0
  75. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/docs-lookup.md +28 -0
  76. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/e2e-runner.md +38 -0
  77. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/performance-optimizer.md +52 -0
  78. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/planner.md +41 -0
  79. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/refactor-cleaner.md +43 -0
  80. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/reference-lookup.md +33 -0
  81. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/security-reviewer.md +47 -0
  82. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/tdd-guide.md +58 -0
  83. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/agents/typescript-reviewer.md +61 -0
  84. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/claude-code/settings.json +87 -0
  85. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/copilot/copilot-instructions.md +15 -0
  86. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/README.md +33 -0
  87. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/architect.mdc +23 -0
  88. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/assistant.mdc +25 -0
  89. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/build-error-resolver.mdc +20 -0
  90. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/code-reviewer.mdc +21 -0
  91. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/database-reviewer.mdc +29 -0
  92. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/docs-lookup.mdc +15 -0
  93. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/e2e-runner.mdc +30 -0
  94. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/performance-optimizer.mdc +27 -0
  95. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/planner.mdc +28 -0
  96. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/refactor-cleaner.mdc +26 -0
  97. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/reference-lookup.mdc +20 -0
  98. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/security-reviewer.mdc +20 -0
  99. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/tdd-guide.mdc +26 -0
  100. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/cursor/rules/typescript-reviewer.mdc +28 -0
  101. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/architect.md +30 -0
  102. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/assistant.md +52 -0
  103. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/build-error-resolver.md +31 -0
  104. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/client-workflow-bootstrap.md +66 -0
  105. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/code-reviewer.md +47 -0
  106. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/contribution-planner.md +44 -0
  107. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/database-reviewer.md +43 -0
  108. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/docs-lookup.md +28 -0
  109. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/e2e-runner.md +33 -0
  110. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/performance-optimizer.md +34 -0
  111. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/planner.md +27 -0
  112. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/refactor-cleaner.md +27 -0
  113. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/reference-lookup.md +27 -0
  114. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/security-reviewer.md +27 -0
  115. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/tdd-guide.md +27 -0
  116. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/agents/typescript-reviewer.md +30 -0
  117. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/opencode/opencode.json +37 -0
  118. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/architect/skill.md +5 -0
  119. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/assistant/skill.md +3 -0
  120. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/code-reviewer/skill.md +5 -0
  121. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/planner/skill.md +3 -0
  122. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/pi/skills/security-reviewer/skill.md +3 -0
  123. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/memories/global_rules.md +74 -0
  124. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/architect.mdc +23 -0
  125. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/build-error-resolver.mdc +20 -0
  126. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/code-reviewer.mdc +21 -0
  127. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/database-reviewer.mdc +29 -0
  128. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/docs-lookup.mdc +15 -0
  129. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/e2e-runner.mdc +30 -0
  130. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/performance-optimizer.mdc +27 -0
  131. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/planner.mdc +28 -0
  132. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/refactor-cleaner.mdc +26 -0
  133. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/security-reviewer.mdc +20 -0
  134. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/tdd-guide.mdc +26 -0
  135. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/profiles/windsurf/rules/typescript-reviewer.mdc +28 -0
  136. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/assistant/SKILL.md +213 -0
  137. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/assistant/references/AGENTS_TEMPLATE.md +0 -0
  138. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/assistant/references/ORCHESTRATION.md +0 -0
  139. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/assistant/references/REPO_INSPECTION.md +0 -0
  140. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/dev-companion/SKILL.md +65 -0
  141. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/dev-companion/references/LOOP_GUARDRAILS.md +0 -0
  142. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/onboarding/SKILL.md +119 -0
  143. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/output-handshake/SKILL.md +24 -0
  144. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/pr-fallback/SKILL.md +33 -0
  145. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/pr-fallback/references/pr-body-default.md +0 -0
  146. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/core/workspace-knowledge-sync/SKILL.md +141 -0
  147. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/data/dbt-validation/SKILL.md +41 -0
  148. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/data/snowflake-validation/SKILL.md +38 -0
  149. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/adr/SKILL.md +46 -0
  150. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/adr/references/default-template.md +0 -0
  151. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/adr/references/example-001-graphql-adoption.md +66 -0
  152. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/agreement/SKILL.md +23 -0
  153. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/agreement/references/default-template.md +0 -0
  154. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/agreement/references/example-api-versioning.md +29 -0
  155. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/bug/SKILL.md +25 -0
  156. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/bug/references/default-template.md +0 -0
  157. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/bug/references/example-search-filter-bug.md +58 -0
  158. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/decision-log/SKILL.md +27 -0
  159. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/decision-log/references/default-template.md +0 -0
  160. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/decision-log/references/example-repository-pattern.md +27 -0
  161. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/development-workflow/SKILL.md +33 -0
  162. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/development-workflow/references/default-template.md +0 -0
  163. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/development-workflow/references/default-workflow-checklist.md +44 -0
  164. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/epic/SKILL.md +21 -0
  165. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/epic/references/default-template.md +0 -0
  166. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/epic/references/example-unified-dashboard.md +40 -0
  167. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/incident/SKILL.md +23 -0
  168. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/incident/references/default-template.md +0 -0
  169. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/incident/references/example-payment-outage.md +62 -0
  170. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/management-unit-assessment/SKILL.md +105 -0
  171. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/management-unit-assessment/references/default-template.md +0 -0
  172. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/management-unit-assessment/references/example-team-assessment.md +126 -0
  173. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/meeting-minutes/SKILL.md +24 -0
  174. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/meeting-minutes/references/default-template.md +0 -0
  175. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/meeting-minutes/references/example-weekly-sync.md +72 -0
  176. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/planning/SKILL.md +30 -0
  177. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/planning/references/default-template.md +0 -0
  178. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/planning/references/example-sprint-planning.md +62 -0
  179. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/prd/SKILL.md +44 -0
  180. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/prd/references/default-template.md +0 -0
  181. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/prd/references/example-unified-api.md +71 -0
  182. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment/SKILL.md +90 -0
  183. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment/references/default-template.md +0 -0
  184. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment/references/example-platform-assessment.md +122 -0
  185. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment-evidence/SKILL.md +88 -0
  186. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment-evidence/references/default-template.md +0 -0
  187. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/project-assessment-evidence/references/example-evidence-map.md +46 -0
  188. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/spike/SKILL.md +23 -0
  189. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/spike/references/default-template.md +0 -0
  190. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/spike/references/example-graphql-federation.md +79 -0
  191. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/task/SKILL.md +20 -0
  192. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/task/references/default-template.md +0 -0
  193. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/task/references/example-nav-system.md +33 -0
  194. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/technical-unit-assessment/SKILL.md +58 -0
  195. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/technical-unit-assessment/references/default-template.md +0 -0
  196. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/technical-unit-assessment/references/example-frontend-assessment.md +112 -0
  197. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/technical-unit-assessment/references/indicator-groups.md +65 -0
  198. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/trd/SKILL.md +47 -0
  199. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/trd/references/default-template.md +0 -0
  200. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/trd/references/example-api-migration.md +122 -0
  201. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/user-story/SKILL.md +20 -0
  202. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/user-story/references/default-template.md +0 -0
  203. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/user-story/references/example-advanced-filtering.md +37 -0
  204. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/work-item/SKILL.md +30 -0
  205. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/work-item/references/default-template.md +0 -0
  206. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/work-item/references/example-routing-dashboard.md +23 -0
  207. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/workflow-client-bootstrap/SKILL.md +148 -0
  208. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/workflow-client-bootstrap/questions.yaml +165 -0
  209. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/delivery/workflow-generic-project/SKILL.md +78 -0
  210. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/LICENSE.txt +2 -0
  211. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/NOTICE.txt +25 -0
  212. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/SKILL.md +95 -0
  213. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/references/figma-mcp-config.md +82 -0
  214. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma/references/figma-tools-and-prompts.md +34 -0
  215. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/LICENSE.txt +2 -0
  216. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/NOTICE.txt +18 -0
  217. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/SKILL.md +350 -0
  218. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/references/mapping-checklist.md +7 -0
  219. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-code-connect-components/scripts/normalize_node_id.py +25 -0
  220. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/LICENSE.txt +2 -0
  221. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/NOTICE.txt +19 -0
  222. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/SKILL.md +550 -0
  223. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-design-system-rules/references/rule-template.md +15 -0
  224. 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
  225. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-new-file/LICENSE.txt +2 -0
  226. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-new-file/NOTICE.txt +17 -0
  227. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-create-new-file/SKILL.md +69 -0
  228. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-implement-design/LICENSE.txt +2 -0
  229. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-implement-design/NOTICE.txt +19 -0
  230. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/figma-implement-design/SKILL.md +264 -0
  231. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/SKILL.md +60 -0
  232. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/charts.csv +26 -0
  233. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/colors.csv +97 -0
  234. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/icons.csv +101 -0
  235. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/landing.csv +31 -0
  236. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/products.csv +97 -0
  237. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/react-performance.csv +45 -0
  238. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/astro.csv +54 -0
  239. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
  240. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
  241. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -0
  242. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
  243. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
  244. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
  245. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
  246. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/react.csv +54 -0
  247. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/shadcn.csv +61 -0
  248. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
  249. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
  250. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/stacks/vue.csv +50 -0
  251. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/styles.csv +68 -0
  252. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/typography.csv +58 -0
  253. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/ui-reasoning.csv +101 -0
  254. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
  255. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/data/web-interface.csv +31 -0
  256. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/references/pre-delivery-checklist.md +39 -0
  257. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/references/search-reference.md +44 -0
  258. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/references/ui-rules.md +35 -0
  259. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/scripts/core.py +253 -0
  260. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/scripts/design_system.py +1067 -0
  261. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/design/ui-ux-pro-max/scripts/search.py +114 -0
  262. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-address-comments/LICENSE.txt +202 -0
  263. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-address-comments/NOTICE.txt +20 -0
  264. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-address-comments/SKILL.md +69 -0
  265. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-address-comments/scripts/fetch_comments.py +237 -0
  266. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-contribution-planner/SKILL.md +157 -0
  267. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-contribution-planner/scripts/inspect_contributions.py +800 -0
  268. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-fix-ci/LICENSE.txt +201 -0
  269. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-fix-ci/NOTICE.txt +20 -0
  270. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-fix-ci/SKILL.md +71 -0
  271. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gh-fix-ci/scripts/inspect_pr_checks.py +509 -0
  272. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/github-cli-workflow/SKILL.md +49 -0
  273. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/gitlab-cli-workflow/SKILL.md +44 -0
  274. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/workflow-client-bootstrap/SKILL.md +14 -0
  275. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/forge/workflow-generic-project/SKILL.md +14 -0
  276. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/clickup-cli/SKILL.md +75 -0
  277. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/clickup-cli/references/docs-management.md +57 -0
  278. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/clickup-cli/references/task-management.md +131 -0
  279. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/clickup-cli/references/time-tracking.md +38 -0
  280. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/linear/LICENSE.txt +202 -0
  281. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/linear/NOTICE.txt +23 -0
  282. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/linear/SKILL.md +131 -0
  283. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/slack-assistant/SKILL.md +134 -0
  284. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/slack-cli/SKILL.md +105 -0
  285. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/integrations/slack-cli/references/INSTALLATION.md +42 -0
  286. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/loops/loop-runner/SKILL.md +90 -0
  287. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/ops/docs-generator/SKILL.md +81 -0
  288. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/ops/llm-cost-advisor/SKILL.md +83 -0
  289. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/ops/triage/SKILL.md +117 -0
  290. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/LICENSE.txt +201 -0
  291. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/NOTICE.txt +27 -0
  292. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/SKILL.md +142 -0
  293. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/assets/experiment-template.ipynb +110 -0
  294. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/assets/tutorial-template.ipynb +107 -0
  295. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/references/experiment-patterns.md +10 -0
  296. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/references/notebook-structure.md +17 -0
  297. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/references/quality-checklist.md +11 -0
  298. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/references/tutorial-patterns.md +9 -0
  299. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/jupyter-notebook/scripts/new_notebook.py +130 -0
  300. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/LICENSE.txt +201 -0
  301. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/NOTICE.txt +29 -0
  302. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/SKILL.md +151 -0
  303. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/references/cli.md +116 -0
  304. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/references/workflows.md +98 -0
  305. agent_toolkit_cli-1.0.3.data/data/agent_toolkit/data/skills/tooling/playwright-cli/scripts/executable_playwright_cli.sh +25 -0
  306. agent_toolkit_cli-1.0.3.dist-info/METADATA +411 -0
  307. agent_toolkit_cli-1.0.3.dist-info/RECORD +310 -0
  308. agent_toolkit_cli-1.0.3.dist-info/WHEEL +4 -0
  309. agent_toolkit_cli-1.0.3.dist-info/entry_points.txt +2 -0
  310. 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()