From 699b431cac5d95fe63fb7ae87ff9e47ba2f854e0 Mon Sep 17 00:00:00 2001 From: csh Date: Mon, 10 Aug 2026 16:55:46 +0800 Subject: [PATCH] :sparkles: feat(workflow): adopt Matt Pocock ticket workflow Replace the Superpowers plan pipeline with grill-with-docs, specs, local tickets, and ticket-native execution. BREAKING CHANGE: Remove the legacy Plan CLI, prompt templates, and Superpowers skills. --- .gitea/ci/thirdparty_skills.json | 46 +- .gitea/workflows/checks.yml | 7 +- .gitea/workflows/prepare.yml | 2 + .gitea/workflows/sync-tsl-playbook.yml | 5 +- .gitea/workflows/update-thirdparty-skills.yml | 61 +- README.md | 13 +- ...book.toml.example => playbook.example.toml | 11 +- pyproject.toml | 2 +- scripts/main_loop.py | 2167 ++++++++--- scripts/playbook.py | 423 +-- skills/README.md | 45 +- .../.sources/matt-pocock-skills.list | 29 + skills/thirdparty/.sources/superpowers.list | 14 - .../thirdparty/ask-matt/PHASE-BOUNDARIES.md | 55 + skills/thirdparty/ask-matt/SKILL.md | 90 + skills/thirdparty/ask-matt/agents/openai.yaml | 5 + skills/thirdparty/brainstorming/SKILL.md | 151 - .../brainstorming/scripts/frame-template.html | 213 -- .../brainstorming/scripts/helper.js | 167 - .../brainstorming/scripts/server.cjs | 723 ---- .../brainstorming/scripts/start-server.sh | 209 -- .../brainstorming/scripts/stop-server.sh | 120 - .../spec-document-reviewer-prompt.md | 49 - .../brainstorming/visual-companion.md | 298 -- skills/thirdparty/code-review/SKILL.md | 87 + .../thirdparty/code-review/agents/openai.yaml | 3 + .../thirdparty/codebase-design/DEEPENING.md | 37 + .../codebase-design/DESIGN-IT-TWICE.md | 44 + skills/thirdparty/codebase-design/SKILL.md | 114 + .../codebase-design/agents/openai.yaml | 3 + skills/thirdparty/diagnosing-bugs/SKILL.md | 140 + .../diagnosing-bugs/agents/openai.yaml | 3 + .../scripts/hitl-loop.template.sh | 44 + .../dispatching-parallel-agents/SKILL.md | 167 - .../thirdparty/domain-modeling/ADR-FORMAT.md | 47 + .../domain-modeling/CONTEXT-FORMAT.md | 60 + skills/thirdparty/domain-modeling/SKILL.md | 74 + .../domain-modeling/agents/openai.yaml | 3 + skills/thirdparty/executing-plans/SKILL.md | 64 - .../finishing-a-development-branch/SKILL.md | 201 - .../git-guardrails-claude-code/SKILL.md | 95 + .../agents/openai.yaml | 3 + .../scripts/block-dangerous-git.sh | 25 + skills/thirdparty/grill-me/SKILL.md | 7 + skills/thirdparty/grill-me/agents/openai.yaml | 5 + skills/thirdparty/grill-with-docs/SKILL.md | 7 + .../grill-with-docs/agents/openai.yaml | 5 + skills/thirdparty/grilling/SKILL.md | 22 + skills/thirdparty/grilling/agents/openai.yaml | 3 + skills/thirdparty/handoff/SKILL.md | 16 + skills/thirdparty/handoff/agents/openai.yaml | 5 + skills/thirdparty/implement/SKILL.md | 15 + .../thirdparty/implement/agents/openai.yaml | 5 + .../HTML-REPORT.md | 123 + .../improve-codebase-architecture/SKILL.md | 71 + .../agents/openai.yaml | 5 + .../thirdparty/migrate-to-shoehorn/SKILL.md | 118 + .../migrate-to-shoehorn/agents/openai.yaml | 3 + skills/thirdparty/prototype/LOGIC.md | 67 + skills/thirdparty/prototype/SKILL.md | 26 + skills/thirdparty/prototype/UI.md | 112 + .../thirdparty/prototype/agents/openai.yaml | 3 + .../thirdparty/receiving-code-review/SKILL.md | 205 - .../requesting-code-review/SKILL.md | 95 - .../requesting-code-review/code-reviewer.md | 172 - skills/thirdparty/research/SKILL.md | 12 + skills/thirdparty/research/agents/openai.yaml | 3 + .../resolving-merge-conflicts/SKILL.md | 14 + .../agents/openai.yaml | 3 + skills/thirdparty/scaffold-exercises/SKILL.md | 106 + .../scaffold-exercises/agents/openai.yaml | 3 + .../setup-matt-pocock-skills/SKILL.md | 116 + .../agents/openai.yaml | 5 + .../setup-matt-pocock-skills/domain.md | 51 + .../issue-tracker-github.md | 45 + .../issue-tracker-gitlab.md | 46 + .../issue-tracker-local.md | 30 + .../setup-matt-pocock-skills/triage-labels.md | 15 + skills/thirdparty/setup-pre-commit/SKILL.md | 91 + .../setup-pre-commit/agents/openai.yaml | 3 + .../subagent-driven-development/SKILL.md | 503 --- .../implementer-prompt.md | 142 - .../re-review-prompt.md | 106 - .../scripts/review-package | 46 - .../scripts/sdd-workspace | 40 - .../scripts/task-brief | 41 - .../task-reviewer-prompt.md | 185 - .../systematic-debugging/CREATION-LOG.md | 119 - .../thirdparty/systematic-debugging/SKILL.md | 283 -- .../condition-based-waiting-example.ts | 158 - .../condition-based-waiting.md | 115 - .../systematic-debugging/defense-in-depth.md | 122 - .../systematic-debugging/find-polluter.sh | 72 - .../root-cause-tracing.md | 169 - .../systematic-debugging/test-academic.md | 14 - .../systematic-debugging/test-pressure-1.md | 58 - .../systematic-debugging/test-pressure-2.md | 68 - .../systematic-debugging/test-pressure-3.md | 69 - skills/thirdparty/tdd/SKILL.md | 38 + skills/thirdparty/tdd/agents/openai.yaml | 3 + skills/thirdparty/tdd/mocking.md | 59 + skills/thirdparty/tdd/tests.md | 77 + skills/thirdparty/teach/GLOSSARY-FORMAT.md | 35 + .../teach/LEARNING-RECORD-FORMAT.md | 46 + skills/thirdparty/teach/MISSION-FORMAT.md | 31 + skills/thirdparty/teach/RESOURCES-FORMAT.md | 32 + skills/thirdparty/teach/SKILL.md | 140 + skills/thirdparty/teach/agents/openai.yaml | 5 + .../test-driven-development/SKILL.md | 320 -- .../writing-good-tests.md | 198 - skills/thirdparty/to-questionnaire/SKILL.md | 53 + .../to-questionnaire/agents/openai.yaml | 5 + skills/thirdparty/to-spec/SKILL.md | 75 + skills/thirdparty/to-spec/agents/openai.yaml | 5 + skills/thirdparty/to-tickets/SKILL.md | 105 + .../thirdparty/to-tickets/agents/openai.yaml | 5 + skills/thirdparty/triage/AGENT-BRIEF.md | 207 ++ skills/thirdparty/triage/OUT-OF-SCOPE.md | 105 + skills/thirdparty/triage/SKILL.md | 112 + skills/thirdparty/triage/agents/openai.yaml | 5 + .../thirdparty/using-git-worktrees/SKILL.md | 167 - skills/thirdparty/using-superpowers/SKILL.md | 62 - .../references/antigravity-tools.md | 23 - .../references/codex-tools.md | 39 - .../references/gemini-tools.md | 63 - .../using-superpowers/references/pi-tools.md | 16 - .../verification-before-completion/SKILL.md | 120 - skills/thirdparty/wait-what/SKILL.md | 7 + .../thirdparty/wait-what/agents/openai.yaml | 5 + skills/thirdparty/wayfinder/SKILL.md | 128 + .../thirdparty/wayfinder/agents/openai.yaml | 5 + skills/thirdparty/wizard/SKILL.md | 44 + skills/thirdparty/wizard/agents/openai.yaml | 3 + skills/thirdparty/wizard/template.sh | 204 + .../writing-for-agents/SKILL-MECHANICS.md | 22 + skills/thirdparty/writing-for-agents/SKILL.md | 81 + .../writing-for-agents/agents/openai.yaml | 3 + skills/thirdparty/writing-plans/SKILL.md | 168 - .../plan-document-reviewer-prompt.md | 49 - skills/thirdparty/writing-skills/SKILL.md | 679 ---- .../anthropic-best-practices.md | 1150 ------ .../examples/CLAUDE_MD_TESTING.md | 189 - .../writing-skills/graphviz-conventions.dot | 172 - .../writing-skills/persuasion-principles.md | 187 - .../writing-skills/render-graphs.js | 168 - .../testing-skills-with-subagents.md | 384 -- templates/AGENTS.template.md | 22 +- templates/AGENT_RULES.template.md | 541 +-- templates/README.md | 265 +- .../memory-bank/active-context.template.md | 53 - templates/memory-bank/decisions.template.md | 33 - templates/memory-bank/progress.template.md | 44 - templates/prompts/README.md | 55 - templates/prompts/coding/clarify.template.md | 54 - .../prompts/coding/close-task.template.md | 75 - .../prompts/coding/code-review.template.md | 57 - .../prompts/coding/update-memory.template.md | 93 - .../prompts/coding/verify-change.template.md | 73 - .../prompts/system/agent-behavior.template.md | 28 - test/README.md | 18 +- test/test_main_loop_cli.py | 3309 ++++++++++++----- test/test_playbook.py | 198 +- test/test_template_contracts.py | 423 ++- test/test_thirdparty_skills_pipeline.py | 110 +- 164 files changed, 9039 insertions(+), 12273 deletions(-) rename playbook.toml.example => playbook.example.toml (88%) create mode 100644 skills/thirdparty/.sources/matt-pocock-skills.list delete mode 100644 skills/thirdparty/.sources/superpowers.list create mode 100644 skills/thirdparty/ask-matt/PHASE-BOUNDARIES.md create mode 100644 skills/thirdparty/ask-matt/SKILL.md create mode 100644 skills/thirdparty/ask-matt/agents/openai.yaml delete mode 100644 skills/thirdparty/brainstorming/SKILL.md delete mode 100644 skills/thirdparty/brainstorming/scripts/frame-template.html delete mode 100644 skills/thirdparty/brainstorming/scripts/helper.js delete mode 100644 skills/thirdparty/brainstorming/scripts/server.cjs delete mode 100755 skills/thirdparty/brainstorming/scripts/start-server.sh delete mode 100755 skills/thirdparty/brainstorming/scripts/stop-server.sh delete mode 100644 skills/thirdparty/brainstorming/spec-document-reviewer-prompt.md delete mode 100644 skills/thirdparty/brainstorming/visual-companion.md create mode 100644 skills/thirdparty/code-review/SKILL.md create mode 100644 skills/thirdparty/code-review/agents/openai.yaml create mode 100644 skills/thirdparty/codebase-design/DEEPENING.md create mode 100644 skills/thirdparty/codebase-design/DESIGN-IT-TWICE.md create mode 100644 skills/thirdparty/codebase-design/SKILL.md create mode 100644 skills/thirdparty/codebase-design/agents/openai.yaml create mode 100644 skills/thirdparty/diagnosing-bugs/SKILL.md create mode 100644 skills/thirdparty/diagnosing-bugs/agents/openai.yaml create mode 100644 skills/thirdparty/diagnosing-bugs/scripts/hitl-loop.template.sh delete mode 100644 skills/thirdparty/dispatching-parallel-agents/SKILL.md create mode 100644 skills/thirdparty/domain-modeling/ADR-FORMAT.md create mode 100644 skills/thirdparty/domain-modeling/CONTEXT-FORMAT.md create mode 100644 skills/thirdparty/domain-modeling/SKILL.md create mode 100644 skills/thirdparty/domain-modeling/agents/openai.yaml delete mode 100644 skills/thirdparty/executing-plans/SKILL.md delete mode 100644 skills/thirdparty/finishing-a-development-branch/SKILL.md create mode 100644 skills/thirdparty/git-guardrails-claude-code/SKILL.md create mode 100644 skills/thirdparty/git-guardrails-claude-code/agents/openai.yaml create mode 100644 skills/thirdparty/git-guardrails-claude-code/scripts/block-dangerous-git.sh create mode 100644 skills/thirdparty/grill-me/SKILL.md create mode 100644 skills/thirdparty/grill-me/agents/openai.yaml create mode 100644 skills/thirdparty/grill-with-docs/SKILL.md create mode 100644 skills/thirdparty/grill-with-docs/agents/openai.yaml create mode 100644 skills/thirdparty/grilling/SKILL.md create mode 100644 skills/thirdparty/grilling/agents/openai.yaml create mode 100644 skills/thirdparty/handoff/SKILL.md create mode 100644 skills/thirdparty/handoff/agents/openai.yaml create mode 100644 skills/thirdparty/implement/SKILL.md create mode 100644 skills/thirdparty/implement/agents/openai.yaml create mode 100644 skills/thirdparty/improve-codebase-architecture/HTML-REPORT.md create mode 100644 skills/thirdparty/improve-codebase-architecture/SKILL.md create mode 100644 skills/thirdparty/improve-codebase-architecture/agents/openai.yaml create mode 100644 skills/thirdparty/migrate-to-shoehorn/SKILL.md create mode 100644 skills/thirdparty/migrate-to-shoehorn/agents/openai.yaml create mode 100644 skills/thirdparty/prototype/LOGIC.md create mode 100644 skills/thirdparty/prototype/SKILL.md create mode 100644 skills/thirdparty/prototype/UI.md create mode 100644 skills/thirdparty/prototype/agents/openai.yaml delete mode 100644 skills/thirdparty/receiving-code-review/SKILL.md delete mode 100644 skills/thirdparty/requesting-code-review/SKILL.md delete mode 100644 skills/thirdparty/requesting-code-review/code-reviewer.md create mode 100644 skills/thirdparty/research/SKILL.md create mode 100644 skills/thirdparty/research/agents/openai.yaml create mode 100644 skills/thirdparty/resolving-merge-conflicts/SKILL.md create mode 100644 skills/thirdparty/resolving-merge-conflicts/agents/openai.yaml create mode 100644 skills/thirdparty/scaffold-exercises/SKILL.md create mode 100644 skills/thirdparty/scaffold-exercises/agents/openai.yaml create mode 100644 skills/thirdparty/setup-matt-pocock-skills/SKILL.md create mode 100644 skills/thirdparty/setup-matt-pocock-skills/agents/openai.yaml create mode 100644 skills/thirdparty/setup-matt-pocock-skills/domain.md create mode 100644 skills/thirdparty/setup-matt-pocock-skills/issue-tracker-github.md create mode 100644 skills/thirdparty/setup-matt-pocock-skills/issue-tracker-gitlab.md create mode 100644 skills/thirdparty/setup-matt-pocock-skills/issue-tracker-local.md create mode 100644 skills/thirdparty/setup-matt-pocock-skills/triage-labels.md create mode 100644 skills/thirdparty/setup-pre-commit/SKILL.md create mode 100644 skills/thirdparty/setup-pre-commit/agents/openai.yaml delete mode 100644 skills/thirdparty/subagent-driven-development/SKILL.md delete mode 100644 skills/thirdparty/subagent-driven-development/implementer-prompt.md delete mode 100644 skills/thirdparty/subagent-driven-development/re-review-prompt.md delete mode 100755 skills/thirdparty/subagent-driven-development/scripts/review-package delete mode 100755 skills/thirdparty/subagent-driven-development/scripts/sdd-workspace delete mode 100755 skills/thirdparty/subagent-driven-development/scripts/task-brief delete mode 100644 skills/thirdparty/subagent-driven-development/task-reviewer-prompt.md delete mode 100644 skills/thirdparty/systematic-debugging/CREATION-LOG.md delete mode 100644 skills/thirdparty/systematic-debugging/SKILL.md delete mode 100644 skills/thirdparty/systematic-debugging/condition-based-waiting-example.ts delete mode 100644 skills/thirdparty/systematic-debugging/condition-based-waiting.md delete mode 100644 skills/thirdparty/systematic-debugging/defense-in-depth.md delete mode 100755 skills/thirdparty/systematic-debugging/find-polluter.sh delete mode 100644 skills/thirdparty/systematic-debugging/root-cause-tracing.md delete mode 100644 skills/thirdparty/systematic-debugging/test-academic.md delete mode 100644 skills/thirdparty/systematic-debugging/test-pressure-1.md delete mode 100644 skills/thirdparty/systematic-debugging/test-pressure-2.md delete mode 100644 skills/thirdparty/systematic-debugging/test-pressure-3.md create mode 100644 skills/thirdparty/tdd/SKILL.md create mode 100644 skills/thirdparty/tdd/agents/openai.yaml create mode 100644 skills/thirdparty/tdd/mocking.md create mode 100644 skills/thirdparty/tdd/tests.md create mode 100644 skills/thirdparty/teach/GLOSSARY-FORMAT.md create mode 100644 skills/thirdparty/teach/LEARNING-RECORD-FORMAT.md create mode 100644 skills/thirdparty/teach/MISSION-FORMAT.md create mode 100644 skills/thirdparty/teach/RESOURCES-FORMAT.md create mode 100644 skills/thirdparty/teach/SKILL.md create mode 100644 skills/thirdparty/teach/agents/openai.yaml delete mode 100644 skills/thirdparty/test-driven-development/SKILL.md delete mode 100644 skills/thirdparty/test-driven-development/writing-good-tests.md create mode 100644 skills/thirdparty/to-questionnaire/SKILL.md create mode 100644 skills/thirdparty/to-questionnaire/agents/openai.yaml create mode 100644 skills/thirdparty/to-spec/SKILL.md create mode 100644 skills/thirdparty/to-spec/agents/openai.yaml create mode 100644 skills/thirdparty/to-tickets/SKILL.md create mode 100644 skills/thirdparty/to-tickets/agents/openai.yaml create mode 100644 skills/thirdparty/triage/AGENT-BRIEF.md create mode 100644 skills/thirdparty/triage/OUT-OF-SCOPE.md create mode 100644 skills/thirdparty/triage/SKILL.md create mode 100644 skills/thirdparty/triage/agents/openai.yaml delete mode 100644 skills/thirdparty/using-git-worktrees/SKILL.md delete mode 100644 skills/thirdparty/using-superpowers/SKILL.md delete mode 100644 skills/thirdparty/using-superpowers/references/antigravity-tools.md delete mode 100644 skills/thirdparty/using-superpowers/references/codex-tools.md delete mode 100644 skills/thirdparty/using-superpowers/references/gemini-tools.md delete mode 100644 skills/thirdparty/using-superpowers/references/pi-tools.md delete mode 100644 skills/thirdparty/verification-before-completion/SKILL.md create mode 100644 skills/thirdparty/wait-what/SKILL.md create mode 100644 skills/thirdparty/wait-what/agents/openai.yaml create mode 100644 skills/thirdparty/wayfinder/SKILL.md create mode 100644 skills/thirdparty/wayfinder/agents/openai.yaml create mode 100644 skills/thirdparty/wizard/SKILL.md create mode 100644 skills/thirdparty/wizard/agents/openai.yaml create mode 100644 skills/thirdparty/wizard/template.sh create mode 100644 skills/thirdparty/writing-for-agents/SKILL-MECHANICS.md create mode 100644 skills/thirdparty/writing-for-agents/SKILL.md create mode 100644 skills/thirdparty/writing-for-agents/agents/openai.yaml delete mode 100644 skills/thirdparty/writing-plans/SKILL.md delete mode 100644 skills/thirdparty/writing-plans/plan-document-reviewer-prompt.md delete mode 100644 skills/thirdparty/writing-skills/SKILL.md delete mode 100644 skills/thirdparty/writing-skills/anthropic-best-practices.md delete mode 100644 skills/thirdparty/writing-skills/examples/CLAUDE_MD_TESTING.md delete mode 100644 skills/thirdparty/writing-skills/graphviz-conventions.dot delete mode 100644 skills/thirdparty/writing-skills/persuasion-principles.md delete mode 100755 skills/thirdparty/writing-skills/render-graphs.js delete mode 100644 skills/thirdparty/writing-skills/testing-skills-with-subagents.md delete mode 100644 templates/memory-bank/active-context.template.md delete mode 100644 templates/memory-bank/decisions.template.md delete mode 100644 templates/memory-bank/progress.template.md delete mode 100644 templates/prompts/README.md delete mode 100644 templates/prompts/coding/clarify.template.md delete mode 100644 templates/prompts/coding/close-task.template.md delete mode 100644 templates/prompts/coding/code-review.template.md delete mode 100644 templates/prompts/coding/update-memory.template.md delete mode 100644 templates/prompts/coding/verify-change.template.md delete mode 100644 templates/prompts/system/agent-behavior.template.md diff --git a/.gitea/ci/thirdparty_skills.json b/.gitea/ci/thirdparty_skills.json index f03a2fad..2eaf2bb8 100644 --- a/.gitea/ci/thirdparty_skills.json +++ b/.gitea/ci/thirdparty_skills.json @@ -1,14 +1,48 @@ { "sources": [ { - "id": "superpowers", - "upstream_repo": "https://github.com/obra/superpowers.git", + "id": "matt-pocock-skills", + "upstream_repo": "https://github.com/mattpocock/skills.git", "upstream_ref": "main", - "snapshot_dir": "superpowers", + "snapshot_dir": "matt-pocock-skills", "sync_mode": "copy_skill_dirs", - "source_list": "skills/thirdparty/.sources/superpowers.list", - "skills_subdir": "skills", - "remove_paths": ["skills/ui-ux-pro-max"] + "source_list": "skills/thirdparty/.sources/matt-pocock-skills.list", + "skills_subdirs": [ + "skills/engineering", + "skills/productivity", + "skills/misc" + ], + "include_skill_dirs": [ + "ask-matt", + "code-review", + "codebase-design", + "diagnosing-bugs", + "domain-modeling", + "git-guardrails-claude-code", + "grill-me", + "grill-with-docs", + "grilling", + "handoff", + "implement", + "improve-codebase-architecture", + "migrate-to-shoehorn", + "prototype", + "research", + "resolving-merge-conflicts", + "scaffold-exercises", + "setup-matt-pocock-skills", + "setup-pre-commit", + "tdd", + "teach", + "to-questionnaire", + "to-spec", + "to-tickets", + "triage", + "wait-what", + "wayfinder", + "wizard", + "writing-for-agents" + ] }, { "id": "ui-ux-pro-max", diff --git a/.gitea/workflows/checks.yml b/.gitea/workflows/checks.yml index 7245341b..d2d60173 100644 --- a/.gitea/workflows/checks.yml +++ b/.gitea/workflows/checks.yml @@ -29,12 +29,13 @@ env: jobs: checks: - # 仅在 Prepare 成功后触发。Prepare 覆盖 push(main) / pull_request / - # schedule / dispatch,故 checks 在这些场景均会运行。 + # 仅在 Prepare 成功后触发;排除 schedule:每日 cron 仅为刷新 thirdparty, + # main 代码未变,无需对同一 HEAD 空跑 lint+全量测试。Prepare 覆盖 + # push(main) / pull_request / dispatch,checks 在这些场景运行。 # lint 与 test 合并为单 job:共用一个临时 worktree,准备一次,顺序执行 # (lint 在前 fail-fast,失败即不跑 test)。二者跑同一 SOURCE_SHA、皆只读、 # 无依赖,无需拆分为并行 job(那会重复整段 worktree 准备逻辑 + 多一个 worktree)。 - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'schedule' }} name: 🔎 规范检查 + 全量测试 runs-on: standard-ubuntu-22 permissions: diff --git a/.gitea/workflows/prepare.yml b/.gitea/workflows/prepare.yml index 2cd63adc..a0c57eca 100644 --- a/.gitea/workflows/prepare.yml +++ b/.gitea/workflows/prepare.yml @@ -91,6 +91,8 @@ jobs: fi done + python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else "Python 3.11 or newer is required")' + { echo "## Prepare 环境" echo "" diff --git a/.gitea/workflows/sync-tsl-playbook.yml b/.gitea/workflows/sync-tsl-playbook.yml index 1e861601..987c08db 100644 --- a/.gitea/workflows/sync-tsl-playbook.yml +++ b/.gitea/workflows/sync-tsl-playbook.yml @@ -33,8 +33,9 @@ env: jobs: sync: - # 仅在 Prepare 于主分支成功后触发。 - if: ${{ github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') }} + # 仅在 Prepare 于主分支成功后触发;排除 schedule:每日 cron 仅为刷新 + # thirdparty,main 代码未变,重建 tsl-playbook 属纯浪费(幂等空推)。 + if: ${{ github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') && github.event.workflow_run.event != 'schedule' }} name: 🔨 构建并发布 tsl-playbook runs-on: standard-ubuntu-22 permissions: diff --git a/.gitea/workflows/update-thirdparty-skills.yml b/.gitea/workflows/update-thirdparty-skills.yml index a39fc22e..4dcd15e2 100644 --- a/.gitea/workflows/update-thirdparty-skills.yml +++ b/.gitea/workflows/update-thirdparty-skills.yml @@ -416,7 +416,12 @@ jobs: entry["snapshot_dir"], entry["sync_mode"], entry["source_list"], - entry.get("skills_subdir", ""), + "\x1e".join( + entry.get( + "skills_subdirs", + [entry.get("skills_subdir", "")], + ) + ), entry.get("output_name", entry["id"]), entry.get("platform_config", ""), entry.get("template_root", ""), @@ -541,7 +546,7 @@ jobs: exit 1 fi - while IFS=$'\x1f' read -r source_id snapshot_dir sync_mode source_list skills_subdir output_name platform_config template_root data_dir scripts_dir include_skill_dirs; do + while IFS=$'\x1f' read -r source_id snapshot_dir sync_mode source_list skills_subdirs output_name platform_config template_root data_dir scripts_dir include_skill_dirs; do [ -n "$source_id" ] || continue if [ -f "$source_list" ]; then @@ -553,7 +558,7 @@ jobs: done < "$sources_file" declare -A owners=() - while IFS=$'\x1f' read -r source_id snapshot_dir sync_mode source_list skills_subdir output_name platform_config template_root data_dir scripts_dir include_skill_dirs; do + while IFS=$'\x1f' read -r source_id snapshot_dir sync_mode source_list skills_subdirs output_name platform_config template_root data_dir scripts_dir include_skill_dirs; do [ -n "$source_id" ] || continue git archive --format=tar "origin/${THIRDPARTY_BRANCH}" "$snapshot_dir" | tar -xf - -C "$tmp_dir" @@ -562,31 +567,37 @@ jobs: names=() case "$sync_mode" in copy_skill_dirs) - source_skills_dir="$snapshot_root/$skills_subdir" - if [ ! -d "$source_skills_dir" ]; then - echo "ERROR: $skills_subdir not found in snapshot $snapshot_dir" >&2 - exit 1 - fi - - for dir in "$source_skills_dir"/*; do - [ -d "$dir" ] || continue - name="$(basename "$dir")" - if ! skill_dir_included "$name" "$include_skill_dirs"; then - continue - fi - if [ -n "${owners[$name]:-}" ] && [ "${owners[$name]}" != "$source_id" ]; then - echo "ERROR: duplicate third-party skill name: $name" >&2 - exit 1 - fi - if tracked_skill_exists "$name"; then - echo "ERROR: skill name conflict with tracked skill: $name" >&2 + local_ifs="$IFS" + IFS=$'\x1e' + read -r -a skill_roots <<< "$skills_subdirs" + IFS="$local_ifs" + for skills_subdir in "${skill_roots[@]}"; do + source_skills_dir="$snapshot_root/$skills_subdir" + if [ ! -d "$source_skills_dir" ]; then + echo "ERROR: $skills_subdir not found in snapshot $snapshot_dir" >&2 exit 1 fi - rm -rf "skills/thirdparty/$name" - cp -R "$dir" "skills/thirdparty/$name" - names+=("$name") - owners["$name"]="$source_id" + for dir in "$source_skills_dir"/*; do + [ -d "$dir" ] || continue + name="$(basename "$dir")" + if ! skill_dir_included "$name" "$include_skill_dirs"; then + continue + fi + if [ -n "${owners[$name]:-}" ] && [ "${owners[$name]}" != "$source_id" ]; then + echo "ERROR: duplicate third-party skill name: $name" >&2 + exit 1 + fi + if tracked_skill_exists "$name"; then + echo "ERROR: skill name conflict with tracked skill: $name" >&2 + exit 1 + fi + + rm -rf "skills/thirdparty/$name" + cp -R "$dir" "skills/thirdparty/$name" + names+=("$name") + owners["$name"]="$source_id" + done done ;; render_skill) diff --git a/README.md b/README.md index f1c540db..499aab09 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,15 @@ Playbook:工程规范与智能体规则合集,当前覆盖: `templates/` 目录除了语言配置模板外,还包含 AI 智能体工作环境的项目架构模板: -- `templates/memory-bank/`:项目上下文文档模板(project-brief、tech-context、system-patterns、active-context、progress、decisions) -- `templates/prompts/`:任务入口模板(agent-behavior、clarify、verify-change、close-task、update-memory、code-review),不是流程权威 +- `templates/memory-bank/`:稳定项目知识模板(project-brief、tech-context、system-patterns) - `templates/AGENTS.template.md`:入口导航模板(项目主入口) -- `templates/AGENT_RULES.template.md`:superpowers-first 执行规则模板 +- `templates/AGENT_RULES.template.md`:Matt Pocock ticket-native 执行规则模板 ### 快速部署 -统一入口(配置驱动,示例见 `playbook.toml.example`): +要求 Python 3.11 或更高版本。 + +统一入口(配置驱动,示例见 `playbook.example.toml`): ```bash python scripts/playbook.py -config playbook.toml @@ -55,8 +56,6 @@ install_mode = "snapshot" [sync_memory_bank] project_name = "MyProject" - -[sync_prompts] ``` **部署行为**: @@ -200,7 +199,7 @@ skills = ["tsl-syntax-reference", "tsl-api-reference"] python docs/standards/playbook/scripts/playbook.py -config playbook.toml ``` -配置参数说明见 [playbook.toml.example](playbook.toml.example)。 +配置参数说明见 [playbook.example.toml](playbook.example.toml)。 --- diff --git a/playbook.toml.example b/playbook.example.toml similarity index 88% rename from playbook.toml.example rename to playbook.example.toml index 7e80e751..20bef447 100644 --- a/playbook.toml.example +++ b/playbook.example.toml @@ -1,4 +1,4 @@ -# playbook.toml (example) +# playbook.example.toml # # 本配置文件用于部署 Playbook 到目标项目,支持两种模式: # - subtree 模式:已通过 git subtree 将 Playbook 引入项目 @@ -33,13 +33,6 @@ # no_backup = false # 跳过备份 # date = "2026-04-22" # 替换 {{DATE}} -[sync_prompts] -# 同步 docs/prompts/ 目录 -# 只更新框架提供的文件,项目新增的文件不会被删除 -# force = false # 覆盖已有文件(会先备份) -# no_backup = false # 跳过备份 -# date = "2026-04-22" # 替换 {{DATE}} - # ============================================================================ # 语言规范同步 # ============================================================================ @@ -57,7 +50,7 @@ [install_skills] # 安装 skills 到本机平台目录 # mode = "list" # list: 指定 skills | all: 全部安装 -# skills = ["brainstorming"] # mode=list 时必填 +# skills = ["commit-message"] # mode=list 时必填;依赖也需逐项列出 # agents_home = "~/.agents" # Codex CLI 用 "~/.agents",Claude Code 用 "~/.claude" # skill_link = "~/.claude" # 可选:在此目录下创建 skills/ 软链接指向 agents_home/skills/ # no_backup = false # 跳过备份,直接删除旧 skill 后重装 diff --git a/pyproject.toml b/pyproject.toml index 0bc8a0d5..08013705 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,4 +2,4 @@ name = "playbook" version = "0.0.0" description = "Playbook templates and tooling" -requires-python = ">=3.10" +requires-python = ">=3.11" diff --git a/scripts/main_loop.py b/scripts/main_loop.py index d1add1d8..42ec0586 100644 --- a/scripts/main_loop.py +++ b/scripts/main_loop.py @@ -1,16 +1,18 @@ #!/usr/bin/env python3 +import argparse from contextlib import contextmanager +from dataclasses import dataclass from datetime import datetime, timezone -import getpass +import json import os -import platform import re -import socket +import subprocess import sys +import tempfile import threading import time from pathlib import Path -from typing import Optional +from typing import Any, Iterator, Optional try: import fcntl @@ -22,200 +24,94 @@ try: except ImportError: # pragma: no cover msvcrt = None -PLAN_STATUS_START = "" -PLAN_STATUS_END = "" -WORKFLOW_STATE_START = "" -WORKFLOW_STATE_END = "" -PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$") -PLAN_LINE_RE = re.compile( - r"^- \[(?P[ xX])\] `(?P[^`]+)` " - r"(?Pdone|blocked|pending|in-progress|skipped)" - r"(?:: (?P.*))?$" + +QUEUE_START = "" +QUEUE_END = "" +TICKET_STATE_START = "" +TICKET_STATE_END = "" +FEATURE_STATE_FILENAME = ".main-loop.json" +CLAIM_STALE_AFTER_SECONDS = 30 * 60 +TICKET_FILE_RE = re.compile(r"^(?P\d{2,})-(?P[a-z0-9][a-z0-9-]*)\.md$") +TITLE_RE = re.compile( + r"^#\s+(?P\d{2,})\s+[-\N{EN DASH}\N{EM DASH}]\s+(?P\S.*)$", + re.MULTILINE, ) -FINISH_STATUSES = {"done", "blocked", "skipped"} -ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$") -PLAN_META_REQUIRED_FIELDS = ( - "Verification Scope", - "Verification Gate", +STATUS_RE = re.compile(r"^\*\*Status:\*\*\s*(?P<status>\S+)\s*$", re.MULTILINE) +BLOCKED_BY_RE = re.compile( + r"^\*\*Blocked by:\*\*\s*(?P<blockers>.+?)\s*$", re.MULTILINE ) +ACCEPTANCE_RE = re.compile(r"^-\s+\[[ xX]\]\s+\S", re.MULTILINE) +NO_BLOCKERS_RE = re.compile( + r"^none(?:\s*[-\N{EN DASH}\N{EM DASH}:]\s*\S.*)?$", re.IGNORECASE +) +BLOCKER_SEPARATOR_RE = re.compile(r";|,(?=\s*\d{2,}\b)") +BLOCKER_ENTRY_RE = re.compile( + r"^\s*(?P<number>\d{2,})(?:\s*[-\N{EN DASH}\N{EM DASH}:]\s*\S.*)?\s*$" +) +QUEUE_ENTRY_RE = re.compile(r"^-\s+`(?P<slug>[a-z0-9][a-z0-9-]*)`\s*$") +FEATURE_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +ALLOWED_STATUSES = { + "ready-for-agent", + "claimed", + "blocked", + "resolved", + "skipped", +} +SATISFIED_STATUSES = {"resolved", "skipped"} THREAD_LOCKS: dict[str, threading.Lock] = {} THREAD_LOCKS_GUARD = threading.Lock() -def usage() -> str: - return ( - "Usage:\n" - " python scripts/main_loop.py claim -plans <dir> -progress <file>\n" - " python scripts/main_loop.py finish -plan <path> -status <status> " - "-progress <file> [-note <text>] [-verified <text>]\n" - " python scripts/main_loop.py status -plans <dir> -progress <file>\n" - " python scripts/main_loop.py -h\n" - "Options:\n" - " -plans DIR\n" - " -plan PATH\n" - " -status done|blocked|skipped\n" - " -progress FILE\n" - " -note TEXT\n" - " -owner NAME\n" - " -verified TEXT\n" - " -h, -help Show this help.\n" - ) +class StateError(ValueError): + pass -def parse_flags(args: list[str]) -> dict[str, str]: - flags: dict[str, str] = {} - idx = 0 - while idx < len(args): - arg = args[idx] - if arg in ("-h", "-help"): - raise ValueError("help") - if not arg.startswith("-"): - raise ValueError(f"unexpected arg: {arg}") - if idx + 1 >= len(args): - raise ValueError(f"missing value for {arg}") - flags[arg] = args[idx + 1] - idx += 2 - return flags +@dataclass(frozen=True) +class Ticket: + number: str + slug: str + title: str + blockers: tuple[str, ...] + status: str + path: Path + metadata: dict[str, Any] -def normalize_plan_key(plan_value: str) -> str: - raw = plan_value.strip().replace("\\", "/") - raw = raw.lstrip("./") - if raw.startswith("docs/superpowers/plans/"): - return raw[len("docs/superpowers/plans/") :] - marker = "/docs/superpowers/plans/" - if marker in raw: - return raw.split(marker, 1)[1] - return raw +@dataclass(frozen=True) +class Feature: + slug: str + path: Path + tickets: dict[str, Ticket] + + @property + def frontier(self) -> list[Ticket]: + return [ + ticket + for ticket in self.tickets.values() + if ticket.status == "ready-for-agent" + and all( + self.tickets[blocker].status in SATISFIED_STATUSES + for blocker in ticket.blockers + ) + ] + + @property + def partial(self) -> bool: + return any(ticket.status == "skipped" for ticket in self.tickets.values()) + + @property + def state(self) -> str: + statuses = {ticket.status for ticket in self.tickets.values()} + if statuses <= SATISFIED_STATUSES: + return "ready-to-integrate" + if "claimed" in statuses or statuses & SATISFIED_STATUSES: + return "active" + if not self.frontier: + return "blocked" + return "queued" -def normalize_note(note: str) -> str: - return note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip() - - -def now_utc() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( - "+00:00", "Z" - ) - - -def default_claim_owner() -> str: - owner = os.environ.get("PLAYBOOK_MAIN_LOOP_OWNER") - if owner: - return normalize_note(owner) - host = socket.gethostname() or "unknown-host" - try: - user = getpass.getuser() - except Exception: # pragma: no cover - user = "unknown-user" - return f"{user}@{host}:{os.getpid()}" - - -def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str: - checked = "x" if status == "done" else " " - suffix = status - if note: - suffix += f": {note}" - return f"- [{checked}] `{plan_key}` {suffix}" - - -def list_plan_files(plans_dir: Path) -> list[str]: - entries: list[str] = [] - for path in plans_dir.iterdir(): - if not path.is_file(): - continue - if not PLAN_FILE_RE.match(path.name): - continue - entries.append(path.name) - return sorted(entries) - - -def find_block(lines: list[str]) -> Optional[tuple[int, int]]: - start_idx = None - for idx, line in enumerate(lines): - if line.strip() == PLAN_STATUS_START: - start_idx = idx - break - if start_idx is None: - return None - for idx in range(start_idx + 1, len(lines)): - if lines[idx].strip() == PLAN_STATUS_END: - return start_idx, idx - return None - - -def find_named_block( - lines: list[str], start_marker: str, end_marker: str -) -> Optional[tuple[int, int]]: - start_idx = None - for idx, line in enumerate(lines): - if line.strip() == start_marker: - start_idx = idx - break - if start_idx is None: - return None - for idx in range(start_idx + 1, len(lines)): - if lines[idx].strip() == end_marker: - return start_idx, idx - return None - - -def parse_entries( - lines: list[str], start_idx: int, end_idx: int -) -> list[tuple[str, str, Optional[str], int]]: - entries: list[tuple[str, str, Optional[str], int]] = [] - for idx in range(start_idx + 1, end_idx): - line = lines[idx].strip() - match = PLAN_LINE_RE.match(line) - if not match: - continue - plan_key = normalize_plan_key(match.group("plan")) - status = match.group("status") - note = match.group("note") - entries.append((plan_key, status, note, idx)) - return entries - - -def render_progress_lines(plans: list[str]) -> list[str]: - lines = [ - "# 当前进展", - "", - "## Plan Status", - "", - PLAN_STATUS_START, - ] - for plan_key in plans: - lines.append(render_plan_line(plan_key, "pending", None)) - lines.append(PLAN_STATUS_END) - return lines - - -def parse_env_blocked_note(note: Optional[str]) -> Optional[tuple[str, str]]: - if not note: - return None - match = ENV_BLOCKED_RE.match(note) - if not match: - return None - return match.group(1), match.group(2) - - -def detect_env() -> Optional[str]: - mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"} - return mapping.get(platform.system().lower()) - - -def load_progress_lines(progress_path: Path) -> list[str]: - progress_path.parent.mkdir(parents=True, exist_ok=True) - if progress_path.exists(): - return progress_path.read_text(encoding="utf-8").splitlines() - return [] - - -def write_progress_lines(progress_path: Path, lines: list[str]) -> None: - progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") - - -def get_thread_lock(lock_path: Path) -> threading.Lock: +def _thread_lock(lock_path: Path) -> threading.Lock: key = str(lock_path.resolve()) with THREAD_LOCKS_GUARD: lock = THREAD_LOCKS.get(key) @@ -226,11 +122,10 @@ def get_thread_lock(lock_path: Path) -> threading.Lock: @contextmanager -def locked_progress(progress_path: Path): - progress_path.parent.mkdir(parents=True, exist_ok=True) - lock_path = progress_path.with_name(f"{progress_path.name}.lock") - thread_lock = get_thread_lock(lock_path) - with thread_lock: +def locked_state(state_root: Path) -> Iterator[None]: + state_root.mkdir(parents=True, exist_ok=True) + lock_path = state_root / ".main-loop.lock" + with _thread_lock(lock_path): with lock_path.open("a+b") as lock_file: if fcntl is not None: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) @@ -243,9 +138,6 @@ def locked_progress(progress_path: Path): except OSError: time.sleep(0.05) try: - hold_ms = os.environ.get("PLAYBOOK_MAIN_LOOP_HOLD_LOCK_MS") - if hold_ms: - time.sleep(max(0.0, float(hold_ms) / 1000.0)) yield finally: if fcntl is not None: @@ -255,363 +147,1612 @@ def locked_progress(progress_path: Path): msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) -def ensure_section(lines: list[str], heading: str) -> list[str]: - if any(line.strip() == heading for line in lines): - return lines - if lines and lines[-1] != "": - lines.append("") - lines.extend([heading, ""]) - return lines - - -def ensure_block_with_lines( - lines: list[str], - start_marker: str, - end_marker: str, - default_lines: list[str], - heading: Optional[str] = None, -) -> tuple[list[str], int, int]: - block = find_named_block(lines, start_marker, end_marker) - if block: - return lines, block[0], block[1] - - if not lines: - lines = ["# 当前进展", ""] - if heading: - lines = ensure_section(lines, heading) - if lines and lines[-1] != "": - lines.append("") - insert_at = len(lines) - lines[insert_at:insert_at] = default_lines - return lines, insert_at, insert_at + len(default_lines) - 1 - - -def ensure_plan_block( - lines: list[str], progress_path: Path, plan_keys: list[str] -) -> tuple[list[str], int, int]: - lines = remove_workflow_state_block(lines) - lines, start_idx, end_idx = ensure_block_with_lines( - lines, - PLAN_STATUS_START, - PLAN_STATUS_END, - [PLAN_STATUS_START, PLAN_STATUS_END], - "## Plan Status", +def atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, raw_temp_path = tempfile.mkstemp( + dir=path.parent, prefix=f".{path.name}.", suffix=".tmp" ) - write_progress_lines(progress_path, lines) - return lines, start_idx, end_idx + temp_path = Path(raw_temp_path) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() -def remove_workflow_state_block(lines: list[str]) -> list[str]: - block = find_named_block(lines, WORKFLOW_STATE_START, WORKFLOW_STATE_END) - if not block: - return lines - - start_idx, end_idx = block - remove_start = start_idx - if ( - remove_start >= 2 - and lines[remove_start - 1].strip() == "" - and lines[remove_start - 2].strip() == "## Workflow State" - ): - remove_start -= 2 - if remove_start > 0 and lines[remove_start - 1].strip() == "": - remove_start -= 1 - - remove_end = end_idx + 1 - if remove_end < len(lines) and lines[remove_end].strip() == "": - remove_end += 1 - del lines[remove_start:remove_end] - return lines +def parse_blockers(raw: str, ticket_path: Path) -> tuple[str, ...]: + value = raw.strip() + if NO_BLOCKERS_RE.fullmatch(value): + return () + blockers: list[str] = [] + for entry in BLOCKER_SEPARATOR_RE.split(value): + match = BLOCKER_ENTRY_RE.fullmatch(entry) + if not match: + raise StateError(f"{ticket_path.name}: malformed Blocked by field") + blockers.append(match.group("number")) + if len(blockers) != len(set(blockers)): + raise StateError(f"{ticket_path.name}: duplicate blocker") + return tuple(blockers) -def ensure_all_plans_present( - lines: list[str], - start_idx: int, - end_idx: int, - progress_path: Path, - plan_keys: list[str], -) -> list[tuple[str, str, Optional[str], int]]: - entries = parse_entries(lines, start_idx, end_idx) - existing = {plan_key for plan_key, _, _, _ in entries} - missing = [plan_key for plan_key in plan_keys if plan_key not in existing] - if missing: - insert_lines = [ - render_plan_line(plan_key, "pending", None) for plan_key in missing - ] - lines[end_idx:end_idx] = insert_lines - write_progress_lines(progress_path, lines) - end_idx += len(insert_lines) - entries = parse_entries(lines, start_idx, end_idx) - return entries +def parse_ticket_metadata(text: str, ticket_path: Path) -> dict[str, Any]: + """Read the scheduler-owned JSON block, if present. + The block is deliberately ordinary Markdown so a ticket remains useful when + opened without the scheduler. Malformed machine state is rejected instead + of silently losing ownership information. + """ + start_count = text.count(TICKET_STATE_START) + end_count = text.count(TICKET_STATE_END) + if start_count == 0 and end_count == 0: + return {} + if start_count > 1 or end_count > 1: + raise StateError(f"{ticket_path.name}: multiple ticket state blocks") + if start_count != 1 or end_count != 1: + raise StateError(f"{ticket_path.name}: malformed ticket state markers") -def ensure_plan_status_entry(lines: list[str], plan_key: str) -> list[str]: - lines = remove_workflow_state_block(lines) - lines, start_idx, end_idx = ensure_block_with_lines( - lines, - PLAN_STATUS_START, - PLAN_STATUS_END, - [PLAN_STATUS_START, PLAN_STATUS_END], - "## Plan Status", + marker = re.compile( + rf"{re.escape(TICKET_STATE_START)}\s*\n(?P<body>.*?)\n" + rf"{re.escape(TICKET_STATE_END)}", + re.DOTALL, ) - entries = parse_entries(lines, start_idx, end_idx) - if plan_key not in {entry_plan for entry_plan, _, _, _ in entries}: - lines[end_idx:end_idx] = [render_plan_line(plan_key, "pending", None)] - return lines + match = marker.search(text) + if not match: + raise StateError(f"{ticket_path.name}: malformed ticket state") + body = match.group("body").strip() + if not body: + return {} + try: + value = json.loads(body) + except json.JSONDecodeError as exc: + raise StateError(f"{ticket_path.name}: malformed ticket state") from exc + if not isinstance(value, dict): + raise StateError(f"{ticket_path.name}: ticket state must be an object") + return value -def filter_existing_entries( - entries: list[tuple[str, str, Optional[str], int]], plan_keys: list[str] -) -> list[tuple[str, str, Optional[str], int]]: - available = set(plan_keys) - return [entry for entry in entries if entry[0] in available] +def render_ticket_state(text: str, metadata: dict[str, Any]) -> str: + body = json.dumps(metadata, ensure_ascii=True, indent=2, sort_keys=True) + block = f"{TICKET_STATE_START}\n{body}\n{TICKET_STATE_END}" + marker = re.compile( + rf"{re.escape(TICKET_STATE_START)}\s*\n.*?\n" + rf"{re.escape(TICKET_STATE_END)}", + re.DOTALL, + ) + if marker.search(text): + updated = marker.sub(lambda _match: block, text, count=1) + else: + separator = "" if not text or text.endswith("\n\n") else "\n" + if not text.endswith("\n"): + separator = "\n\n" + updated = f"{text}{separator}{block}\n" + return updated -def validate_plan_meta(plan_path: Path) -> list[str]: - text = plan_path.read_text(encoding="utf-8") - missing: list[str] = [] - if not re.search(r"(?im)^##\s+Plan Meta\s*$", text): - missing.append("Plan Meta") - for field in PLAN_META_REQUIRED_FIELDS: - pattern = rf"(?im)^\s*[-*]\s+(?:\*\*)?{re.escape(field)}(?:\*\*)?\s*:" - if not re.search(pattern, text): - missing.append(field) - return missing +def update_ticket_state( + ticket: Ticket, + *, + status: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, +) -> None: + if status is not None and status not in ALLOWED_STATUSES: + raise StateError(f"invalid status {status}") + text = ticket.path.read_text(encoding="utf-8") + if status is not None: + status_pattern = re.compile(r"(?m)^\*\*Status:\*\*\s*[^\r\n]*$") + if not status_pattern.search(text): + raise StateError(f"{ticket.path.name}: missing Status") + text = status_pattern.sub(f"**Status:** {status}", text, count=1) + if metadata is not None: + text = render_ticket_state(text, metadata) + atomic_write_text(ticket.path, text) -def validate_plan_files(plans_dir: Path, plan_keys: list[str]) -> Optional[str]: - for plan_key in plan_keys: - missing = validate_plan_meta(plans_dir / plan_key) - if missing: - fields = ", ".join(missing) - return f"ERROR: {plan_key} missing required Plan Meta fields: {fields}" - return None +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) -def choose_claim_entry( - entries: list[tuple[str, str, Optional[str], int]], - current_env: Optional[str], - plan_keys: list[str], -) -> Optional[tuple[str, Optional[str], int]]: - available = set(plan_keys) - ordered_entries = [entry for entry in entries if entry[0] in available] +def parse_timestamp(raw: str) -> datetime: + try: + value = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError as exc: + raise StateError(f"invalid timestamp: {raw}") from exc + if value.tzinfo is None: + raise StateError(f"timestamp must include a timezone: {raw}") + return value.astimezone(timezone.utc) - for plan_key, status, note, idx in ordered_entries: - if status == "in-progress": - return plan_key, note, idx - for plan_key, status, note, idx in ordered_entries: - if status == "pending": - return plan_key, note, idx - if status != "blocked" or not current_env: +def format_timestamp(value: datetime) -> str: + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + + +def claim_is_stale(ticket: Ticket, now: datetime) -> bool: + heartbeat = ticket.metadata.get("heartbeat_at") + if not heartbeat: + raise StateError(f"{ticket.path.name}: claimed ticket has no heartbeat") + return ( + now - parse_timestamp(str(heartbeat)) + ).total_seconds() > CLAIM_STALE_AFTER_SECONDS + + +def git_run(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + +def git_output(repo_root: Path, *args: str) -> str: + result = git_run(repo_root, *args) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise StateError(f"git {' '.join(args)} failed: {detail}") + return result.stdout.strip() + + +def resolve_repo_root(repo_root: Path) -> Path: + candidate = repo_root.resolve() + if not candidate.exists(): + raise StateError(f"repo root not found: {repo_root}") + raw = git_output(candidate, "rev-parse", "--show-toplevel") + return Path(raw).resolve() + + +def branch_exists(repo_root: Path, branch: str) -> bool: + return git_run(repo_root, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}").returncode == 0 + + +def current_branch(repo_root: Path) -> str: + return git_output(repo_root, "branch", "--show-current") + + +def worktree_branch_paths(repo_root: Path) -> dict[str, Path]: + output = git_output(repo_root, "worktree", "list", "--porcelain") + paths: dict[str, Path] = {} + current_path: Optional[Path] = None + for line in output.splitlines(): + if line.startswith("worktree "): + current_path = Path(line[len("worktree ") :]).resolve() + elif line.startswith("branch refs/heads/") and current_path is not None: + branch = line[len("branch refs/heads/") :] + paths[branch] = current_path + return paths + + +def checkout_is_dirty(repo_root: Path) -> Optional[str]: + output = git_output(repo_root, "status", "--porcelain", "--untracked-files=all") + for line in output.splitlines(): + if len(line) < 4: continue - env_info = parse_env_blocked_note(note) - if env_info and env_info[0] == current_env: - return plan_key, note, idx - + raw_path = line[3:].strip().strip('"') + normalized = raw_path.replace("\\", "/") + if normalized == ".scratch" or normalized.startswith(".scratch/"): + continue + return raw_path return None -def claim_plan( - plans_dir: Path, progress_path: Path, owner: Optional[str] = None -) -> tuple[int, str]: - if not plans_dir.is_dir(): - return 2, f"ERROR: plans dir not found: {plans_dir}" +def ensure_git_repository(repo_root: Path) -> None: + result = git_run(repo_root, "rev-parse", "--git-dir") + if result.returncode != 0: + raise StateError(f"not a Git repository: {repo_root}") - plan_keys = list_plan_files(plans_dir) - if not plan_keys: - return 2, "ERROR: no plan files found" - plan_error = validate_plan_files(plans_dir, plan_keys) - if plan_error: - return 2, plan_error - with locked_progress(progress_path): - lines = load_progress_lines(progress_path) - try: - lines, start_idx, end_idx = ensure_plan_block( - lines, progress_path, plan_keys +def feature_state_path(feature: Feature) -> Path: + return feature.path / FEATURE_STATE_FILENAME + + +def load_feature_state(feature: Feature) -> dict[str, Any]: + path = feature_state_path(feature) + if not path.exists(): + return {} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise StateError(f"{path.name}: malformed feature state") from exc + if not isinstance(value, dict): + raise StateError(f"{path.name}: feature state must be an object") + return value + + +def write_feature_state(feature: Feature, state: dict[str, Any]) -> None: + atomic_write_text( + feature_state_path(feature), + json.dumps(state, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + ) + + +def parse_ticket(path: Path) -> Ticket: + file_match = TICKET_FILE_RE.fullmatch(path.name) + if not file_match: + raise StateError(f"invalid ticket filename: {path.name}") + text = path.read_text(encoding="utf-8") + title_match = TITLE_RE.search(text) + if not title_match: + raise StateError(f"{path.name}: invalid title") + number = file_match.group("number") + if title_match.group("number") != number: + raise StateError(f"{path.name}: title number does not match filename") + status_match = STATUS_RE.search(text) + if not status_match: + raise StateError(f"{path.name}: missing Status") + status = status_match.group("status") + if status not in ALLOWED_STATUSES: + raise StateError(f"{path.name}: invalid status {status}") + blocked_by_match = BLOCKED_BY_RE.search(text) + if not blocked_by_match: + raise StateError(f"{path.name}: missing Blocked by") + if not ACCEPTANCE_RE.search(text): + raise StateError(f"{path.name}: missing acceptance criterion") + metadata = parse_ticket_metadata(text, path) + return Ticket( + number=number, + slug=file_match.group("slug"), + title=title_match.group("title").strip(), + blockers=parse_blockers(blocked_by_match.group("blockers"), path), + status=status, + path=path, + metadata=metadata, + ) + + +def validate_acyclic(tickets: dict[str, Ticket]) -> None: + visiting: set[str] = set() + visited: set[str] = set() + + def visit(number: str) -> None: + if number in visiting: + raise StateError(f"ticket dependency cycle includes {number}") + if number in visited: + return + visiting.add(number) + for blocker in tickets[number].blockers: + visit(blocker) + visiting.remove(number) + visited.add(number) + + for number in tickets: + visit(number) + + +def load_feature(state_root: Path, slug: str) -> Feature: + if not FEATURE_SLUG_RE.fullmatch(slug): + raise StateError(f"invalid feature slug: {slug}") + feature_path = state_root / slug + if not (feature_path / "spec.md").is_file(): + raise StateError(f"{slug}: spec.md not found") + issues_path = feature_path / "issues" + if not issues_path.is_dir(): + raise StateError(f"{slug}: issues directory not found") + ticket_paths = sorted(path for path in issues_path.iterdir() if path.is_file()) + if not ticket_paths: + raise StateError(f"{slug}: no ticket files found") + tickets: dict[str, Ticket] = {} + for path in ticket_paths: + ticket = parse_ticket(path) + if ticket.number in tickets: + raise StateError(f"{slug}: duplicate ticket number {ticket.number}") + tickets[ticket.number] = ticket + for ticket in tickets.values(): + for blocker in ticket.blockers: + if blocker not in tickets: + raise StateError(f"{ticket.path.name}: unknown blocker {blocker}") + validate_acyclic(tickets) + return Feature(slug=slug, path=feature_path, tickets=tickets) + + +def load_queue(queue_path: Path) -> list[str]: + if not queue_path.exists(): + return [] + lines = queue_path.read_text(encoding="utf-8").splitlines() + if lines.count(QUEUE_START) != 1 or lines.count(QUEUE_END) != 1: + raise StateError("queue.md has an invalid managed block") + try: + start = lines.index(QUEUE_START) + end = lines.index(QUEUE_END, start + 1) + except ValueError as exc: + raise StateError("queue.md has an invalid managed block") from exc + slugs: list[str] = [] + for line in lines[start + 1 : end]: + if not line.strip(): + continue + match = QUEUE_ENTRY_RE.fullmatch(line.strip()) + if not match: + raise StateError(f"queue.md has an invalid entry: {line.strip()}") + slug = match.group("slug") + if slug in slugs: + raise StateError(f"queue.md has a duplicate feature: {slug}") + slugs.append(slug) + return slugs + + +def render_queue(slugs: list[str]) -> str: + entries = [f"- `{slug}`" for slug in slugs] + return "\n".join( + ["# Feature Queue", "", QUEUE_START, "", *entries, "", QUEUE_END, ""] + ) + + +def enqueue_feature(state_root: Path, slug: str) -> str: + queue_path = state_root / "queue.md" + with locked_state(state_root): + load_feature(state_root, slug) + slugs = load_queue(queue_path) + if slug in slugs: + return f"EXISTS={slug}" + slugs.append(slug) + atomic_write_text(queue_path, render_queue(slugs)) + return f"ENQUEUED={slug}" + + +def status_report( + state_root: Path, + now: Optional[datetime] = None, +) -> str: + observed_at = now or datetime.now(timezone.utc) + with locked_state(state_root): + slugs = load_queue(state_root / "queue.md") + if not slugs: + return "NO FEATURES" + output: list[str] = [] + for slug in slugs: + feature = load_feature(state_root, slug) + feature_metadata = load_feature_state(feature) + feature_status = ( + "integrated" + if feature_metadata.get("integration_commit") + else ( + "blocked" + if feature_metadata.get("integration_blocked_reason") + else feature.state + ) ) - except ValueError as exc: - return 2, f"ERROR: {exc}" - - entries = ensure_all_plans_present( - lines, start_idx, end_idx, progress_path, plan_keys - ) - entries = filter_existing_entries(entries, plan_keys) - chosen = choose_claim_entry(entries, detect_env(), plan_keys) - if not chosen: - return 0, "NOOP: no claimable plans" - - plan_key, note, idx = chosen - claim_note = ( - f"claimed_by: {normalize_note(owner) if owner else default_claim_owner()}; " - f"claimed_at: {now_utc()}" - ) - updated_note = f"{note}; {claim_note}" if note else claim_note - lines[idx] = render_plan_line(plan_key, "in-progress", updated_note) - write_progress_lines(progress_path, lines) - - output = [f"PLAN={(plans_dir / plan_key).as_posix()}"] - if note: - output.append(f"NOTE={note}") - return 0, "\n".join(output) - - -def finish_plan( - plan: str, - status: str, - progress_path: Path, - note: Optional[str], - verified: Optional[str] = None, -) -> tuple[int, str]: - if status not in FINISH_STATUSES: - return 2, f"ERROR: invalid status: {status}" - if not plan: - return 2, "ERROR: plan is required" - if verified and status != "done": - return 2, "ERROR: -verified is only valid with -status done" - - plan_key = normalize_plan_key(plan) - with locked_progress(progress_path): - lines = load_progress_lines(progress_path) - - try: - lines, start_idx, end_idx = ensure_plan_block( - lines, progress_path, [plan_key] + output.append( + f"FEATURE={slug} STATE={feature_status} " + f"PARTIAL={'yes' if feature.partial else 'no'}" ) - except ValueError as exc: - return 2, f"ERROR: {exc}" - - entries = parse_entries(lines, start_idx, end_idx) - rendered_note = normalize_note(note) if note else None - rendered_verified = normalize_note(verified) if verified else None - if rendered_verified: - verified_note = f"verified: {rendered_verified}" - rendered_note = ( - f"{verified_note}; note: {rendered_note}" - if rendered_note - else verified_note + frontier = "" if feature_status == "integrated" else ",".join( + ticket.number for ticket in feature.frontier ) - updated_line = render_plan_line(plan_key, status, rendered_note) - - for entry_plan, _, _, idx in entries: - if entry_plan == plan_key: - lines[idx] = updated_line - write_progress_lines(progress_path, lines) - return 0, updated_line - - lines[end_idx:end_idx] = [updated_line] - write_progress_lines(progress_path, lines) - return 0, updated_line + output.append(f"FRONTIER={frontier or '-'}") + if feature_metadata.get("integration_commit"): + output.append( + f"MAIN_INTEGRATION_COMMIT={feature_metadata['integration_commit']}" + ) + elif feature_metadata.get("integration_blocked_reason"): + output.append( + f"FEATURE_BLOCKED={slug} " + f"REASON={feature_metadata['integration_blocked_reason']}" + ) + for ticket in feature.tickets.values(): + if ticket.status == "claimed": + stale = claim_is_stale(ticket, observed_at) + output.append( + f"CLAIM={slug}/{ticket.number} " + f"OWNER={ticket.metadata.get('claimed_by')} " + f"HEARTBEAT={ticket.metadata.get('heartbeat_at')} " + f"STALE={'yes' if stale else 'no'} " + f"ISOLATION={ticket.metadata.get('isolation')} " + f"WORKSPACE={ticket.metadata.get('workspace')}" + ) + if ticket.metadata.get("last_error"): + output.append( + f"TICKET_ERROR={slug}/{ticket.number} " + f"REASON={ticket.metadata['last_error']}" + ) + elif ticket.status == "blocked": + output.append( + f"BLOCKED={slug}/{ticket.number} " + f"REASON={ticket.metadata.get('blocked_reason', 'unspecified')}" + ) + return "\n".join(output) -def status_report(plans_dir: Path, progress_path: Path) -> tuple[int, str]: - if not plans_dir.is_dir(): - return 2, f"ERROR: plans dir not found: {plans_dir}" - - plan_keys = list_plan_files(plans_dir) - lines = load_progress_lines(progress_path) - block = find_block(lines) - entries: list[tuple[str, str, Optional[str], int]] = [] - if block: - entries = filter_existing_entries(parse_entries(lines, *block), plan_keys) - - entry_by_plan = {plan_key: (status, note) for plan_key, status, note, _ in entries} - counts = {status: 0 for status in ("pending", "in-progress", "done", "blocked", "skipped")} - rows: list[str] = [] - for plan_key in plan_keys: - status, note = entry_by_plan.get(plan_key, ("pending", None)) - counts[status] += 1 - suffix = f": {note}" if note else "" - rows.append(f"PLAN {plan_key} {status}{suffix}") - - output = [ - "STATUS " - f"total={len(plan_keys)} " - f"pending={counts['pending']} " - f"in-progress={counts['in-progress']} " - f"done={counts['done']} " - f"blocked={counts['blocked']} " - f"skipped={counts['skipped']}" +def render_claim_context(feature: Feature, ticket: Ticket, metadata: dict[str, Any]) -> str: + values = [ + ("FEATURE", feature.slug), + ("TICKET", ticket.number), + ("CONTROL_ROOT", str(metadata["control_root"])), + ("STATE_ROOT", str(metadata["state_root"])), + ("WORKSPACE", str(metadata["workspace"])), + ("BRANCH", str(metadata["ticket_branch"])), + ("BASE", str(metadata["base_commit"])), + ("ISOLATION", str(metadata["isolation"])), ] - output.extend(rows) - return 0, "\n".join(output) + return "\n".join(f"{key}={value}" for key, value in values) -def record_plan(progress_path: Path, plan: str) -> tuple[int, str]: - with locked_progress(progress_path): - lines = load_progress_lines(progress_path) - lines = ensure_plan_status_entry(lines, normalize_plan_key(plan)) - write_progress_lines(progress_path, lines) - return 0, "OK" +def active_claims(features: list[Feature]) -> list[tuple[Feature, Ticket]]: + claims: list[tuple[Feature, Ticket]] = [] + for feature in features: + for ticket in feature.tickets.values(): + if ticket.status == "claimed": + if not ticket.metadata.get("claimed_by"): + raise StateError(f"{ticket.path.name}: claimed ticket has no owner") + claims.append((feature, ticket)) + return claims + + +def checkout_branch(repo_root: Path, branch: str) -> None: + occupied = worktree_branch_paths(repo_root).get(branch) + if occupied is not None and os.path.normcase(str(occupied)) != os.path.normcase( + str(repo_root.resolve()) + ): + raise StateError(f"branch {branch} is checked out at {occupied}") + result = git_run(repo_root, "checkout", branch) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise StateError(f"cannot checkout {branch}: {detail}") + + +def prepare_in_place_claim( + repo_root: Path, + feature: Feature, + ticket: Ticket, + main_branch: str, + prior_metadata: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + ensure_git_repository(repo_root) + branch = current_branch(repo_root) + if not branch: + raise StateError("in-place claim requires an attached HEAD") + feature_branch = f"feature/{feature.slug}" + ticket_branch = f"ticket/{feature.slug}/{ticket.number}-{ticket.slug}" + prior_workspace = prior_metadata.get("workspace") if prior_metadata else None + restores_released_workspace = bool( + prior_metadata + and prior_metadata.get("isolation") == "in-place" + and prior_workspace + and Path(str(prior_workspace)).resolve() == repo_root.resolve() + and prior_metadata.get("ticket_branch") == ticket_branch + and branch == ticket_branch + ) + dirty_path = checkout_is_dirty(repo_root) + if dirty_path and not restores_released_workspace: + raise StateError(f"in-place checkout is dirty: {dirty_path}") + if not branch_exists(repo_root, main_branch): + raise StateError(f"main branch not found: {main_branch}") + + if not branch_exists(repo_root, feature_branch): + git_output(repo_root, "branch", feature_branch, main_branch) + feature_head = git_output(repo_root, "rev-parse", feature_branch) + + if not branch_exists(repo_root, ticket_branch): + git_output(repo_root, "branch", ticket_branch, feature_branch) + if branch != ticket_branch: + checkout_branch(repo_root, ticket_branch) + control_root = repo_root.resolve() + return { + "isolation": "in-place", + "control_root": str(control_root), + "workspace": str(control_root), + "feature_branch": feature_branch, + "ticket_branch": ticket_branch, + "base_commit": feature_head, + } + + +def add_worktree( + repo_root: Path, + workspace: Path, + branch: str, + start_point: str, +) -> None: + registered = worktree_branch_paths(repo_root).get(branch) + if registered is not None: + if os.path.normcase(str(registered)) == os.path.normcase(str(workspace.resolve())): + if not workspace.is_dir(): + raise StateError(f"registered worktree is missing: {workspace}") + return + raise StateError(f"branch {branch} is checked out at {registered}") + if workspace.exists(): + if any(workspace.iterdir()): + raise StateError(f"worktree path is not empty: {workspace}") + else: + workspace.parent.mkdir(parents=True, exist_ok=True) + + if branch_exists(repo_root, branch): + command = ("worktree", "add", str(workspace), branch) + else: + command = ("worktree", "add", "-b", branch, str(workspace), start_point) + result = git_run(repo_root, *command) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise StateError(f"cannot create worktree for {branch}: {detail}") + + +def prepare_worktree_claim( + repo_root: Path, + state_root: Path, + feature: Feature, + ticket: Ticket, + main_branch: str, + prior_metadata: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + ensure_git_repository(repo_root) + if not branch_exists(repo_root, main_branch): + raise StateError(f"main branch not found: {main_branch}") + feature_branch = f"feature/{feature.slug}" + ticket_branch = f"ticket/{feature.slug}/{ticket.number}-{ticket.slug}" + if not branch_exists(repo_root, feature_branch): + git_output(repo_root, "branch", feature_branch, main_branch) + + worktree_root = state_root / "worktrees" + feature_state = load_feature_state(feature) + integration_workspace = Path( + str( + feature_state.get( + "integration_workspace", + worktree_root / "_integration" / feature.slug, + ) + ) + ).resolve() + occupied = worktree_branch_paths(repo_root).get(feature_branch) + if occupied is not None and os.path.normcase(str(occupied)) == os.path.normcase( + str(repo_root.resolve()) + ): + dirty_path = checkout_is_dirty(repo_root) + if dirty_path: + raise StateError( + f"cannot free feature branch from dirty control checkout: {dirty_path}" + ) + checkout_branch(repo_root, main_branch) + add_worktree(repo_root, integration_workspace, feature_branch, main_branch) + + prior_workspace = prior_metadata.get("workspace") if prior_metadata else None + ticket_workspace = Path( + str( + prior_workspace + or worktree_root / feature.slug / f"{ticket.number}-{ticket.slug}" + ) + ).resolve() + feature_head = git_output(repo_root, "rev-parse", feature_branch) + add_worktree( + repo_root, + ticket_workspace, + ticket_branch, + feature_branch, + ) + + feature_state.update( + { + "feature_branch": feature_branch, + "integration_workspace": str(integration_workspace), + } + ) + write_feature_state(feature, feature_state) + return { + "isolation": "worktree", + "control_root": str(repo_root.resolve()), + "workspace": str(ticket_workspace), + "feature_branch": feature_branch, + "ticket_branch": ticket_branch, + "base_commit": feature_head, + "integration_workspace": str(integration_workspace), + } + + +def resume_claim( + repo_root: Path, + feature: Feature, + ticket: Ticket, + owner: str, +) -> str: + metadata = dict(ticket.metadata) + isolation = metadata.get("isolation") + workspace = Path(str(metadata.get("workspace", ""))).resolve() + if isolation == "in-place": + if workspace != repo_root.resolve(): + raise StateError( + f"claim workspace mismatch: expected {workspace}, got {repo_root.resolve()}" + ) + branch = current_branch(repo_root) + if not branch: + raise StateError("in-place claim requires an attached HEAD") + ticket_branch = str(metadata.get("ticket_branch", "")) + if branch != ticket_branch: + dirty_path = checkout_is_dirty(repo_root) + if dirty_path: + raise StateError(f"in-place checkout is dirty: {dirty_path}") + checkout_branch(repo_root, ticket_branch) + elif isolation == "worktree": + if not workspace.is_dir(): + raise StateError(f"claim workspace not found: {workspace}") + ticket_branch = str(metadata.get("ticket_branch", "")) + if current_branch(workspace) != ticket_branch: + raise StateError(f"claim workspace is not on {ticket_branch}: {workspace}") + else: + raise StateError(f"{ticket.path.name}: invalid claim isolation") + metadata["heartbeat_at"] = utc_now() + update_ticket_state(ticket, metadata=metadata) + return render_claim_context(feature, ticket, metadata) + + +def choose_ticket_for_claim(features: list[Feature]) -> tuple[Optional[Feature], Optional[Ticket], str]: + for feature in features: + state = load_feature_state(feature) + if state.get("integration_commit"): + continue + if state.get("integration_blocked_reason"): + continue + if feature.state == "ready-to-integrate": + return feature, None, "integration-required" + frontier = sorted(feature.frontier, key=lambda item: (int(item.number), item.slug)) + if frontier: + return feature, frontier[0], "claim" + if any(ticket.status == "claimed" for ticket in feature.tickets.values()): + return feature, None, "busy" + # A feature with no frontier and no active claim is genuinely blocked; + # later queued features may be developed, but still integrate in order. + return None, None, "none" + + +def claim_ticket( + state_root: Path, + repo_root: Path, + owner: str, + isolation: str, + main_branch: str, +) -> str: + if not owner.strip(): + raise StateError("owner must not be empty") + state_root = state_root.resolve() + repo_root = resolve_repo_root(repo_root) + with locked_state(state_root): + slugs = load_queue(state_root / "queue.md") + if not slugs: + return "NO FEATURES" + features = [load_feature(state_root, slug) for slug in slugs] + claims = active_claims(features) + + for feature, ticket in claims: + if ticket.metadata.get("claimed_by") == owner: + return resume_claim(repo_root, feature, ticket, owner) + + if claims and isolation == "in-place": + return "BUSY" + if any(ticket.metadata.get("isolation") == "in-place" for _, ticket in claims): + return "BUSY" + + feature, ticket, disposition = choose_ticket_for_claim(features) + if disposition == "integration-required" and feature is not None: + return f"INTEGRATION_REQUIRED={feature.slug}" + if disposition == "busy": + return "BUSY" + if ticket is None or feature is None: + return "NOOP: no claimable tickets" + + selected_isolation = isolation + if isolation == "auto": + selected_isolation = "in-place" if not claims else "worktree" + try: + if selected_isolation == "in-place": + execution = prepare_in_place_claim( + repo_root, feature, ticket, main_branch, ticket.metadata + ) + else: + execution = prepare_worktree_claim( + repo_root, + state_root, + feature, + ticket, + main_branch, + ticket.metadata, + ) + except (OSError, StateError) as exc: + now = utc_now() + reason = f"claim preparation failed: {exc}" + metadata = dict(ticket.metadata) + append_history( + metadata, + "claim-blocked", + now, + owner=owner, + reason=reason, + ) + metadata.update( + { + "claimed_by": owner, + "claimed_at": now, + "blocked_at": now, + "blocked_reason": reason, + "requested_isolation": selected_isolation, + } + ) + metadata.pop("heartbeat_at", None) + update_ticket_state(ticket, status="blocked", metadata=metadata) + raise StateError(reason) from exc + now = utc_now() + metadata = dict(ticket.metadata) + execution["state_root"] = str(state_root) + metadata.update(execution) + metadata.update( + { + "claimed_by": owner, + "claimed_at": now, + "heartbeat_at": now, + } + ) + update_ticket_state(ticket, status="claimed", metadata=metadata) + return render_claim_context(feature, ticket, metadata) + + +def evidence_field( + evidence: str, + key: str, + label: str, + expected: Optional[str] = None, +) -> str: + matches = re.findall( + rf"(?:^|[;,])\s*{re.escape(key)}\s*=\s*([^;,]+)", + evidence, + re.IGNORECASE, + ) + if not matches or not matches[0].strip(): + required = f"{key}={expected}" if expected is not None else key + raise StateError(f"{label} evidence must include {required}") + if len(matches) != 1: + raise StateError(f"{label} evidence must include exactly one {key}") + value = matches[0].strip() + if expected is not None and value.lower() != expected.lower(): + raise StateError(f"{label} evidence must include {key}={expected}") + return value + + +def require_review_passed(reviewed: str) -> tuple[str, str]: + for axis in ("standards", "spec"): + evidence_field(reviewed, axis, "review", "pass") + return ( + evidence_field(reviewed, "commit", "review"), + evidence_field(reviewed, "base", "review"), + ) + + +def require_verification_passed(evidence: str, label: str) -> str: + if not evidence.strip(): + raise StateError(f"{label} evidence is required") + evidence_field(evidence, "result", label, "pass") + return evidence_field(evidence, "commit", label) + + +def normalize_commit(repo_root: Path, commit: str, label: str) -> str: + result = git_run(repo_root, "cat-file", "-e", f"{commit}^{{commit}}") + if result.returncode != 0: + raise StateError(f"{label} commit not found: {commit}") + return git_output(repo_root, "rev-parse", f"{commit}^{{commit}}") + + +def is_ancestor(repo_root: Path, ancestor: str, descendant: str) -> bool: + return git_run(repo_root, "merge-base", "--is-ancestor", ancestor, descendant).returncode == 0 + + +def find_no_ff_merge( + repo_root: Path, + head: str, + merged_head: str, + label: str, +) -> tuple[str, str]: + merges = git_run(repo_root, "rev-list", "--first-parent", "--merges", head) + if merges.returncode != 0: + detail = (merges.stderr or merges.stdout).strip() + raise StateError(f"cannot inspect {label} integration history: {detail}") + for merge_commit in merges.stdout.splitlines(): + parents = git_output( + repo_root, + "rev-list", + "--parents", + "-n", + "1", + merge_commit, + ).split() + if len(parents) == 3 and parents[2] == merged_head: + return merge_commit, parents[1] + raise StateError(f"cannot recover {label} integration merge") + + +def append_history( + metadata: dict[str, Any], event: str, at: str, **details: str +) -> None: + raw_history = metadata.get("history", []) + history = list(raw_history) if isinstance(raw_history, list) else [] + history.append({"event": event, "at": at, **details}) + metadata["history"] = history + + +def heartbeat_ticket( + state_root: Path, + feature_slug: str, + ticket_number: str, + owner: str, + at: datetime, +) -> str: + with locked_state(state_root.resolve()): + feature = load_feature(state_root.resolve(), feature_slug) + ticket = feature.tickets.get(ticket_number) + if ticket is None: + raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + if ticket.status != "claimed": + raise StateError(f"ticket {feature_slug}/{ticket_number} is not claimed") + if ticket.metadata.get("claimed_by") != owner: + raise StateError(f"ticket {feature_slug}/{ticket_number} is owned by another session") + metadata = dict(ticket.metadata) + timestamp = format_timestamp(at) + metadata["heartbeat_at"] = timestamp + update_ticket_state(ticket, metadata=metadata) + return f"HEARTBEAT={feature_slug}/{ticket_number}\nAT={timestamp}" + + +def validate_reclaim_workspace(repo_root: Path, ticket: Ticket) -> None: + metadata = ticket.metadata + workspace = Path(str(metadata.get("workspace", ""))).resolve() + if not workspace.is_dir(): + raise StateError(f"claim workspace not found: {workspace}") + isolation = metadata.get("isolation") + if isolation == "in-place" and workspace != repo_root.resolve(): + raise StateError( + f"claim workspace mismatch: expected {workspace}, got {repo_root.resolve()}" + ) + ticket_branch = str(metadata.get("ticket_branch", "")) + if current_branch(workspace) != ticket_branch: + raise StateError(f"claim workspace is not on {ticket_branch}: {workspace}") + + +def reclaim_ticket( + state_root: Path, + repo_root: Path, + feature_slug: str, + ticket_number: str, + owner: str, + at: datetime, +) -> str: + state_root = state_root.resolve() + repo_root = resolve_repo_root(repo_root) + with locked_state(state_root): + feature = load_feature(state_root, feature_slug) + ticket = feature.tickets.get(ticket_number) + if ticket is None: + raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + if ticket.status != "claimed": + raise StateError(f"ticket {feature_slug}/{ticket_number} is not claimed") + previous_owner = str(ticket.metadata.get("claimed_by", "")) + if not previous_owner: + raise StateError(f"ticket {feature_slug}/{ticket_number} has no owner") + if previous_owner == owner: + raise StateError("reclaim requires a different owner") + if not claim_is_stale(ticket, at): + raise StateError(f"ticket {feature_slug}/{ticket_number} is not stale") + validate_reclaim_workspace(repo_root, ticket) + + metadata = dict(ticket.metadata) + timestamp = format_timestamp(at) + append_history( + metadata, + "reclaim", + timestamp, + previous_owner=previous_owner, + owner=owner, + ) + metadata.update( + { + "claimed_by": owner, + "claimed_at": timestamp, + "heartbeat_at": timestamp, + } + ) + update_ticket_state(ticket, metadata=metadata) + return render_claim_context(feature, ticket, metadata) + + +def finish_nonresolved_ticket( + state_root: Path, + feature_slug: str, + ticket_number: str, + owner: str, + result: str, + reason: str, +) -> str: + state_root = state_root.resolve() + with locked_state(state_root): + feature = load_feature(state_root, feature_slug) + ticket = feature.tickets.get(ticket_number) + if ticket is None: + raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + allowed_source = ticket.status == "claimed" or ( + result == "released" and ticket.status == "blocked" + ) + if not allowed_source: + raise StateError( + f"ticket {feature_slug}/{ticket_number} cannot become {result} " + f"from {ticket.status}" + ) + metadata = dict(ticket.metadata) + if metadata.get("claimed_by") != owner: + raise StateError(f"ticket {feature_slug}/{ticket_number} is owned by another session") + if result in {"blocked", "skipped"} and not reason.strip(): + raise StateError(f"{result} requires a reason") + + timestamp = utc_now() + append_history(metadata, result, timestamp, owner=owner, reason=reason.strip()) + if result == "blocked": + status = "blocked" + metadata["blocked_reason"] = reason.strip() + metadata["blocked_at"] = timestamp + elif result == "released": + status = "ready-for-agent" + metadata["last_owner"] = owner + metadata["released_at"] = timestamp + metadata.pop("blocked_reason", None) + metadata.pop("blocked_at", None) + metadata.pop("claimed_by", None) + metadata.pop("claimed_at", None) + metadata.pop("heartbeat_at", None) + else: + status = "skipped" + metadata["last_owner"] = owner + metadata["skipped_reason"] = reason.strip() + metadata["skipped_at"] = timestamp + metadata.pop("claimed_by", None) + metadata.pop("claimed_at", None) + metadata.pop("heartbeat_at", None) + update_ticket_state(ticket, status=status, metadata=metadata) + return f"{result.upper()}={feature_slug}/{ticket_number}" + + +def finish_resolved_ticket( + state_root: Path, + repo_root: Path, + feature_slug: str, + ticket_number: str, + owner: str, + implementation_commit: str, + feature_head: str, + review_base: str, + verified: str, + reviewed: str, +) -> str: + if not implementation_commit: + raise StateError("implementation commit is required") + if not feature_head: + raise StateError("feature head is required") + if not review_base: + raise StateError("review base is required") + verification_commit = require_verification_passed(verified, "verification") + review_commit, reviewed_base = require_review_passed(reviewed) + state_root = state_root.resolve() + repo_root = resolve_repo_root(repo_root) + + with locked_state(state_root): + feature = load_feature(state_root, feature_slug) + ticket = feature.tickets.get(ticket_number) + if ticket is None: + raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + metadata = dict(ticket.metadata) + if ticket.status == "resolved" and metadata.get("integration_commit"): + return ( + f"RESOLVED={feature_slug}/{ticket_number}\n" + f"INTEGRATION_COMMIT={metadata['integration_commit']}" + ) + if ticket.status != "claimed": + raise StateError(f"ticket {feature_slug}/{ticket_number} is not claimed") + if metadata.get("claimed_by") != owner: + raise StateError(f"ticket {feature_slug}/{ticket_number} is owned by another session") + isolation = metadata.get("isolation") + expected_workspace = Path(str(metadata.get("workspace", ""))).resolve() + if isolation == "in-place": + if expected_workspace != repo_root.resolve(): + raise StateError( + f"claim workspace mismatch: expected {expected_workspace}, " + f"got {repo_root.resolve()}" + ) + ticket_workspace = repo_root + integration_workspace = repo_root + elif isolation == "worktree": + if not expected_workspace.is_dir(): + raise StateError(f"ticket workspace not found: {expected_workspace}") + ticket_workspace = expected_workspace + feature_state = load_feature_state(feature) + integration_value = feature_state.get("integration_workspace") + if not integration_value: + raise StateError(f"{feature_slug}: integration workspace is not recorded") + integration_workspace = Path(str(integration_value)).resolve() + if not integration_workspace.is_dir(): + raise StateError( + f"integration workspace not found: {integration_workspace}" + ) + else: + raise StateError(f"{ticket.path.name}: invalid claim isolation") + + dirty_path = checkout_is_dirty(ticket_workspace) + if dirty_path: + raise StateError(f"ticket checkout is dirty: {dirty_path}") + + ticket_branch = str(metadata.get("ticket_branch", "")) + feature_branch = str(metadata.get("feature_branch", "")) + if not branch_exists(repo_root, ticket_branch): + raise StateError(f"ticket branch not found: {ticket_branch}") + if not branch_exists(repo_root, feature_branch): + raise StateError(f"feature branch not found: {feature_branch}") + ticket_tip = git_output(repo_root, "rev-parse", ticket_branch) + implementation_commit = normalize_commit( + repo_root, implementation_commit, "implementation" + ) + feature_head = normalize_commit(repo_root, feature_head, "feature head") + review_base = normalize_commit(repo_root, review_base, "review base") + verification_commit = normalize_commit( + repo_root, verification_commit, "verification" + ) + review_commit = normalize_commit(repo_root, review_commit, "review") + reviewed_base = normalize_commit( + repo_root, reviewed_base, "review base evidence" + ) + if implementation_commit != ticket_tip: + raise StateError( + "implementation commit must equal ticket branch HEAD" + ) + if verification_commit != ticket_tip: + raise StateError( + "verification commit must equal ticket branch HEAD" + ) + if review_commit != ticket_tip: + raise StateError("review commit must equal ticket branch HEAD") + if review_base != feature_head: + raise StateError( + "review base must equal the verified feature head" + ) + if reviewed_base != review_base: + raise StateError( + "review evidence base must equal --review-base" + ) + if current_branch(ticket_workspace) != ticket_branch: + raise StateError( + f"ticket workspace is not on its branch: {ticket_workspace}" + ) + + current_feature_head = git_output(repo_root, "rev-parse", feature_branch) + already_integrated = is_ancestor(repo_root, ticket_tip, current_feature_head) + recovered_integration_commit: Optional[str] = None + if already_integrated: + recovered_integration_commit, integration_base = find_no_ff_merge( + repo_root, + current_feature_head, + ticket_tip, + "ticket", + ) + if feature_head != integration_base: + raise StateError( + "verified feature head must equal the ticket integration base" + ) + else: + if current_feature_head != feature_head: + return f"RETRY: feature advanced\nFEATURE_HEAD={current_feature_head}" + if not is_ancestor(repo_root, feature_head, ticket_tip): + raise StateError( + "ticket branch must include the verified feature head" + ) + + if not already_integrated: + integration_dirty = checkout_is_dirty(integration_workspace) + if integration_dirty: + raise StateError( + f"integration checkout is dirty: {integration_dirty}" + ) + if current_branch(integration_workspace) != feature_branch: + checkout_branch(integration_workspace, feature_branch) + merge = git_run( + integration_workspace, + "merge", + "--no-ff", + "-m", + f"Integrate {feature_slug}/{ticket_number}", + ticket_branch, + ) + if merge.returncode != 0: + detail = (merge.stderr or merge.stdout).strip() + metadata["last_error"] = f"merge failed: {detail}" + update_ticket_state(ticket, metadata=metadata) + raise StateError(f"ticket merge failed: {detail}") + elif current_branch(integration_workspace) != feature_branch: + checkout_branch(integration_workspace, feature_branch) + + integration_commit = ( + recovered_integration_commit + if recovered_integration_commit is not None + else git_output(repo_root, "rev-parse", feature_branch) + ) + metadata.update( + { + "implementation_commit": implementation_commit, + "ticket_head": ticket_tip, + "integration_commit": integration_commit, + "verified": verified, + "reviewed": reviewed, + "resolved_at": utc_now(), + } + ) + metadata.pop("last_error", None) + update_ticket_state(ticket, status="resolved", metadata=metadata) + return ( + f"RESOLVED={feature_slug}/{ticket_number}\n" + f"INTEGRATION_COMMIT={integration_commit}" + ) + + +def branch_workspace(repo_root: Path, branch: str) -> Optional[Path]: + return worktree_branch_paths(repo_root).get(branch) + + +def prepare_main_workspace(repo_root: Path, main_branch: str) -> Path: + workspace = branch_workspace(repo_root, main_branch) + if workspace is not None: + dirty_path = checkout_is_dirty(workspace) + if dirty_path: + raise StateError(f"main checkout is dirty: {dirty_path}") + return workspace + dirty_path = checkout_is_dirty(repo_root) + if dirty_path: + raise StateError(f"control checkout is dirty: {dirty_path}") + if not current_branch(repo_root): + raise StateError("control checkout has detached HEAD") + checkout_branch(repo_root, main_branch) + return repo_root + + +def path_is_within(path: Path, parent: Path) -> bool: + try: + return os.path.commonpath((str(path.resolve()), str(parent.resolve()))) == str( + parent.resolve() + ) + except ValueError: + return False + + +def remove_clean_worktree( + repo_root: Path, workspace: Path, allowed_root: Path +) -> Optional[str]: + workspace = workspace.resolve() + if not path_is_within(workspace, allowed_root): + return f"preserved unexpected worktree path {workspace}" + if not workspace.exists(): + return None + dirty_path = checkout_is_dirty(workspace) + if dirty_path: + return f"preserved dirty worktree {workspace}: {dirty_path}" + result = git_run(repo_root, "worktree", "remove", str(workspace)) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + return f"could not remove worktree {workspace}: {detail}" + return None + + +def cleanup_integrated_feature_worktrees( + repo_root: Path, state_root: Path, feature: Feature +) -> list[str]: + warnings: list[str] = [] + allowed_root = (state_root / "worktrees").resolve() + for ticket in feature.tickets.values(): + if ticket.status not in SATISFIED_STATUSES: + continue + if ticket.metadata.get("isolation") != "worktree": + continue + workspace_value = ticket.metadata.get("workspace") + if not workspace_value: + continue + warning = remove_clean_worktree( + repo_root, Path(str(workspace_value)), allowed_root + ) + if warning: + warnings.append(warning) + + feature_metadata = load_feature_state(feature) + integration_value = feature_metadata.get("integration_workspace") + if integration_value: + warning = remove_clean_worktree( + repo_root, Path(str(integration_value)), allowed_root + ) + if warning: + warnings.append(warning) + return warnings + + +def block_feature_integration(state_root: Path, feature_slug: str, reason: str) -> str: + if not reason.strip(): + raise StateError("feature block requires a reason") + state_root = state_root.resolve() + with locked_state(state_root): + slugs = load_queue(state_root / "queue.md") + if feature_slug not in slugs: + raise StateError(f"feature is not queued: {feature_slug}") + feature = load_feature(state_root, feature_slug) + state = load_feature_state(feature) + if state.get("integration_commit"): + raise StateError(f"feature is already integrated: {feature_slug}") + if feature.state != "ready-to-integrate": + raise StateError( + f"feature {feature_slug} is not ready to integrate: {feature.state}" + ) + timestamp = utc_now() + state["integration_blocked_reason"] = reason.strip() + state["integration_blocked_at"] = timestamp + append_history( + state, + "feature-blocked", + timestamp, + reason=reason.strip(), + ) + write_feature_state(feature, state) + return f"FEATURE_BLOCKED={feature_slug}" + + +def release_feature_integration(state_root: Path, feature_slug: str) -> str: + state_root = state_root.resolve() + with locked_state(state_root): + slugs = load_queue(state_root / "queue.md") + if feature_slug not in slugs: + raise StateError(f"feature is not queued: {feature_slug}") + feature = load_feature(state_root, feature_slug) + state = load_feature_state(feature) + if not state.get("integration_blocked_reason"): + raise StateError(f"feature is not blocked: {feature_slug}") + timestamp = utc_now() + append_history(state, "feature-released", timestamp) + state.pop("integration_blocked_reason", None) + state.pop("integration_blocked_at", None) + write_feature_state(feature, state) + return f"FEATURE_RELEASED={feature_slug}" + + +def integrate_feature( + state_root: Path, + repo_root: Path, + feature_slug: str, + feature_head: str, + verified: str, + main_verified: str, + reviewed: str, + main_branch: str, + allow_partial: bool, +) -> str: + if not feature_head: + raise StateError("feature head is required") + feature_verification_commit = require_verification_passed( + verified, "feature verification" + ) + main_verification_commit = require_verification_passed( + main_verified, "main candidate verification" + ) + review_commit, reviewed_base = require_review_passed(reviewed) + state_root = state_root.resolve() + repo_root = resolve_repo_root(repo_root) + + with locked_state(state_root): + slugs = load_queue(state_root / "queue.md") + if feature_slug not in slugs: + raise StateError(f"feature is not queued: {feature_slug}") + queued_features = [load_feature(state_root, slug) for slug in slugs] + requested = next( + feature for feature in queued_features if feature.slug == feature_slug + ) + requested_state = load_feature_state(requested) + if requested_state.get("integration_commit"): + warnings = cleanup_integrated_feature_worktrees( + repo_root, state_root, requested + ) + output = [ + f"INTEGRATED={feature_slug}", + f"MAIN_INTEGRATION_COMMIT={requested_state['integration_commit']}", + ] + output.extend(f"WARNING={warning}" for warning in warnings) + return "\n".join(output) + if requested_state.get("integration_blocked_reason"): + raise StateError( + f"feature {feature_slug} is blocked: " + f"{requested_state['integration_blocked_reason']}" + ) + + first_pending: Optional[Feature] = None + for feature in queued_features: + if not load_feature_state(feature).get("integration_commit"): + first_pending = feature + break + if first_pending is None: + return "NOOP: all features integrated" + if first_pending.slug != feature_slug: + raise StateError( + f"cannot integrate {feature_slug}: earlier feature " + f"{first_pending.slug} is not integrated" + ) + if requested.state != "ready-to-integrate": + raise StateError( + f"feature {feature_slug} is not ready to integrate: {requested.state}" + ) + if requested.partial and not allow_partial: + raise StateError( + f"feature {feature_slug} is partial; pass --allow-partial explicitly" + ) + + feature_branch = str( + requested_state.get("feature_branch", f"feature/{feature_slug}") + ) + if not branch_exists(repo_root, feature_branch): + raise StateError(f"feature branch not found: {feature_branch}") + if not branch_exists(repo_root, main_branch): + raise StateError(f"main branch not found: {main_branch}") + current_feature_head = git_output(repo_root, "rev-parse", feature_branch) + feature_head = normalize_commit(repo_root, feature_head, "feature head") + if current_feature_head != feature_head: + return f"RETRY: feature advanced\nFEATURE_HEAD={current_feature_head}" + main_head = git_output(repo_root, "rev-parse", main_branch) + already_integrated = is_ancestor( + repo_root, current_feature_head, main_head + ) + if not already_integrated and not is_ancestor( + repo_root, main_head, current_feature_head + ): + return f"RETRY: feature needs main sync\nMAIN_HEAD={main_head}" + + feature_verification_commit = normalize_commit( + repo_root, + feature_verification_commit, + "feature verification", + ) + main_verification_commit = normalize_commit( + repo_root, + main_verification_commit, + "main candidate verification", + ) + review_commit = normalize_commit(repo_root, review_commit, "review") + reviewed_base = normalize_commit( + repo_root, reviewed_base, "review base evidence" + ) + if feature_verification_commit != current_feature_head: + raise StateError( + "feature verification commit must equal feature HEAD" + ) + if main_verification_commit != current_feature_head: + raise StateError( + "main candidate verification commit must equal feature HEAD" + ) + if review_commit != current_feature_head: + raise StateError("review commit must equal feature HEAD") + recovered_integration_commit: Optional[str] = None + if already_integrated: + if current_feature_head == main_head: + expected_review_base = main_head + recovered_integration_commit = main_head + else: + ( + recovered_integration_commit, + expected_review_base, + ) = find_no_ff_merge( + repo_root, + main_head, + current_feature_head, + "feature", + ) + if reviewed_base != expected_review_base: + raise StateError( + "review base must equal the pre-integration main HEAD" + ) + elif reviewed_base != main_head: + raise StateError("review base must equal the latest main HEAD") + + if already_integrated: + if recovered_integration_commit is None: + raise StateError("feature integration commit was not recovered") + integration_commit = recovered_integration_commit + else: + main_workspace = prepare_main_workspace(repo_root, main_branch) + latest_main = git_output(repo_root, "rev-parse", main_branch) + if latest_main != main_head: + return f"RETRY: feature needs main sync\nMAIN_HEAD={latest_main}" + merge = git_run( + main_workspace, + "merge", + "--no-ff", + "-m", + f"Integrate feature {feature_slug}", + feature_branch, + ) + if merge.returncode != 0: + detail = (merge.stderr or merge.stdout).strip() + requested_state["last_error"] = f"main merge failed: {detail}" + requested_state["integration_blocked_reason"] = ( + f"main merge failed: {detail}" + ) + requested_state["integration_blocked_at"] = utc_now() + write_feature_state(requested, requested_state) + raise StateError(f"feature merge failed: {detail}") + + integration_commit = git_output(repo_root, "rev-parse", main_branch) + requested_state.update( + { + "feature_branch": feature_branch, + "feature_head": current_feature_head, + "integration_commit": integration_commit, + "verified": verified, + "main_verified": main_verified, + "reviewed": reviewed, + "partial_authorized": bool(allow_partial), + "integrated_at": utc_now(), + } + ) + requested_state.pop("last_error", None) + write_feature_state(requested, requested_state) + warnings = cleanup_integrated_feature_worktrees( + repo_root, state_root, requested + ) + output = [ + f"INTEGRATED={feature_slug}", + f"MAIN_INTEGRATION_COMMIT={integration_commit}", + ] + output.extend(f"WARNING={warning}" for warning in warnings) + return "\n".join(output) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="main_loop.py") + subparsers = parser.add_subparsers(dest="command", required=True) + + enqueue = subparsers.add_parser("enqueue") + enqueue.add_argument("--state-root", default=".scratch") + enqueue.add_argument("--feature", required=True) + + status = subparsers.add_parser("status") + status.add_argument("--state-root", default=".scratch") + + claim = subparsers.add_parser("claim") + claim.add_argument("--state-root", default=".scratch") + claim.add_argument("--repo-root", default=".") + claim.add_argument("--owner", required=True) + claim.add_argument( + "--isolation", choices=("auto", "in-place", "worktree"), default="auto" + ) + claim.add_argument("--main-branch", default="main") + + finish = subparsers.add_parser("finish") + finish.add_argument("--state-root", default=".scratch") + finish.add_argument("--repo-root", default=".") + finish.add_argument("--feature", required=True) + finish.add_argument("--ticket", required=True) + finish.add_argument("--owner", required=True) + finish.add_argument( + "--result", choices=("resolved", "blocked", "released", "skipped"), required=True + ) + finish.add_argument("--implementation-commit", default="") + finish.add_argument("--feature-head", default="") + finish.add_argument("--review-base", default="") + finish.add_argument("--verified", default="") + finish.add_argument("--reviewed", default="") + finish.add_argument("--reason", default="") + + heartbeat = subparsers.add_parser("heartbeat") + heartbeat.add_argument("--state-root", default=".scratch") + heartbeat.add_argument("--feature", required=True) + heartbeat.add_argument("--ticket", required=True) + heartbeat.add_argument("--owner", required=True) + + reclaim = subparsers.add_parser("reclaim") + reclaim.add_argument("--state-root", default=".scratch") + reclaim.add_argument("--repo-root", default=".") + reclaim.add_argument("--feature", required=True) + reclaim.add_argument("--ticket", required=True) + reclaim.add_argument("--owner", required=True) + + block_feature = subparsers.add_parser("block-feature") + block_feature.add_argument("--state-root", default=".scratch") + block_feature.add_argument("--feature", required=True) + block_feature.add_argument("--reason", required=True) + + release_feature = subparsers.add_parser("release-feature") + release_feature.add_argument("--state-root", default=".scratch") + release_feature.add_argument("--feature", required=True) + + integrate = subparsers.add_parser("integrate") + integrate.add_argument("--state-root", default=".scratch") + integrate.add_argument("--repo-root", default=".") + integrate.add_argument("--feature", required=True) + integrate.add_argument("--feature-head", required=True) + integrate.add_argument("--verified", required=True) + integrate.add_argument("--main-verified", required=True) + integrate.add_argument("--reviewed", required=True) + integrate.add_argument("--main-branch", default="main") + integrate.add_argument("--allow-partial", action="store_true") + return parser def main(argv: list[str]) -> int: - if not argv: - print(usage(), file=sys.stderr) - return 2 - if argv[0] in ("-h", "-help"): - print(usage()) - return 0 - - mode = argv[0] - if mode not in {"claim", "finish", "status"}: - print(f"ERROR: unknown mode: {mode}", file=sys.stderr) - print(usage(), file=sys.stderr) - return 2 - + args = build_parser().parse_args(argv) + state_root = Path(args.state_root) try: - flags = parse_flags(argv[1:]) - except ValueError as exc: - if str(exc) == "help": - print(usage()) - return 0 + if args.command == "enqueue": + message = enqueue_feature(state_root, args.feature) + elif args.command == "status": + message = status_report(state_root) + elif args.command == "claim": + message = claim_ticket( + state_root, + Path(args.repo_root), + args.owner, + args.isolation, + args.main_branch, + ) + elif args.command == "heartbeat": + message = heartbeat_ticket( + state_root, + args.feature, + args.ticket, + args.owner, + datetime.now(timezone.utc), + ) + elif args.command == "reclaim": + message = reclaim_ticket( + state_root, + Path(args.repo_root), + args.feature, + args.ticket, + args.owner, + datetime.now(timezone.utc), + ) + elif args.command == "block-feature": + message = block_feature_integration( + state_root, + args.feature, + args.reason, + ) + elif args.command == "release-feature": + message = release_feature_integration( + state_root, + args.feature, + ) + elif args.command == "integrate": + message = integrate_feature( + state_root, + Path(args.repo_root), + args.feature, + args.feature_head, + args.verified, + args.main_verified, + args.reviewed, + args.main_branch, + args.allow_partial, + ) + elif args.result == "resolved": + message = finish_resolved_ticket( + state_root, + Path(args.repo_root), + args.feature, + args.ticket, + args.owner, + args.implementation_commit, + args.feature_head, + args.review_base, + args.verified, + args.reviewed, + ) + else: + message = finish_nonresolved_ticket( + state_root, + args.feature, + args.ticket, + args.owner, + args.result, + args.reason, + ) + except (OSError, StateError, UnicodeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) - print(usage(), file=sys.stderr) return 2 - - if mode == "claim": - plans = flags.get("-plans") - progress = flags.get("-progress") - owner = flags.get("-owner") - if not plans or not progress: - print("ERROR: -plans and -progress are required", file=sys.stderr) - print(usage(), file=sys.stderr) - return 2 - code, message = claim_plan(Path(plans), Path(progress), owner) - if code != 0: - print(message, file=sys.stderr) - return code - print(message) - return 0 - - if mode == "status": - plans = flags.get("-plans") - progress = flags.get("-progress") - if not plans or not progress: - print("ERROR: -plans and -progress are required", file=sys.stderr) - print(usage(), file=sys.stderr) - return 2 - code, message = status_report(Path(plans), Path(progress)) - if code != 0: - print(message, file=sys.stderr) - return code - print(message) - return 0 - - plan = flags.get("-plan") - status = flags.get("-status") - progress = flags.get("-progress") - note = flags.get("-note") - verified = flags.get("-verified") - if not plan or not status or not progress: - print("ERROR: -plan, -status, and -progress are required", file=sys.stderr) - print(usage(), file=sys.stderr) - return 2 - code, message = finish_plan(plan, status, Path(progress), note, verified) - if code != 0: - print(message, file=sys.stderr) - return code print(message) return 0 diff --git a/scripts/playbook.py b/scripts/playbook.py index 0eee41ea..5e04c659 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -1,41 +1,22 @@ #!/usr/bin/env python3 +import argparse import re import sys +import tomllib from datetime import datetime, timezone from pathlib import Path from shutil import copy2, copytree, ignore_patterns, rmtree, which import subprocess -import importlib.util -from typing import Optional - -try: - import tomllib -except ModuleNotFoundError: # Python < 3.11 - tomllib = None ORDER = [ "sync_rules", "sync_memory_bank", - "sync_prompts", "sync_standards", "install_skills", "format_md", ] SCRIPT_DIR = Path(__file__).resolve().parent PLAYBOOK_ROOT = SCRIPT_DIR.parent -MAIN_LOOP_SCRIPT = SCRIPT_DIR / "main_loop.py" -MAIN_LOOP_SPEC = importlib.util.spec_from_file_location("playbook_main_loop", MAIN_LOOP_SCRIPT) -assert MAIN_LOOP_SPEC and MAIN_LOOP_SPEC.loader -MAIN_LOOP = importlib.util.module_from_spec(MAIN_LOOP_SPEC) -MAIN_LOOP_SPEC.loader.exec_module(MAIN_LOOP) -PATH_CONFIG_KEYS = { - "project_root", - "playbook_root", - "deploy_root", - "agents_home", - "codex_home", - "skill_link", -} DOCS_INDEX_SECTION_HEADINGS = { "common": "## 跨语言(common)", "tsl": "## TSL(tsl/tsf)", @@ -46,199 +27,8 @@ DOCS_INDEX_SECTION_HEADINGS = { } -def usage() -> str: - return ( - "Usage:\n" - " python scripts/playbook.py -config <path>\n" - " python scripts/playbook.py -record-plan <plan_path> -progress <path>\n" - " python scripts/playbook.py -h" - ) - - -def parse_cli_value(argv: list[str], flag: str) -> Optional[str]: - if flag not in argv: - return None - idx = argv.index(flag) - if idx + 1 >= len(argv): - return None - value = argv[idx + 1].strip() - return value or None - - -def strip_inline_comment(value: str) -> str: - in_single = False - in_double = False - escape = False - for idx, ch in enumerate(value): - if escape: - escape = False - continue - if in_double and ch == "\\": - escape = True - continue - if ch == "'" and not in_double: - in_single = not in_single - continue - if ch == '"' and not in_single: - in_double = not in_double - continue - if ch == "#" and not in_single and not in_double: - return value[:idx].rstrip() - return value - - -def split_list_items(raw: str) -> list[str]: - items: list[str] = [] - buf: list[str] = [] - in_single = False - in_double = False - escape = False - for ch in raw: - if escape: - buf.append(ch) - escape = False - continue - if in_double and ch == "\\": - buf.append(ch) - escape = True - continue - if ch == "'" and not in_double: - in_single = not in_single - buf.append(ch) - continue - if ch == '"' and not in_single: - in_double = not in_double - buf.append(ch) - continue - if ch == "," and not in_single and not in_double: - items.append("".join(buf).strip()) - buf = [] - continue - buf.append(ch) - tail = "".join(buf).strip() - if tail: - items.append(tail) - return items - - -def parse_toml_value(raw: str) -> object: - value = raw.strip() - if not value: - return "" - if value.startswith("[") and value.endswith("]"): - inner = value[1:-1].strip() - if not inner: - return [] - return [parse_toml_value(item) for item in split_list_items(inner)] - lowered = value.lower() - if lowered == "true": - return True - if lowered == "false": - return False - if value[0] in ("'", '"') and value[-1] == value[0]: - if value[0] == "'": - return value[1:-1] - import ast - - try: - return ast.literal_eval(value) - except (ValueError, SyntaxError): - return value[1:-1] - try: - if "." in value: - return float(value) - return int(value) - except ValueError: - return value - - -def loads_toml_minimal(raw: str) -> dict: - data: dict[str, dict] = {} - current = None - for line in raw.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - if stripped.startswith("[") and stripped.endswith("]"): - section = stripped[1:-1].strip() - if not section: - raise ValueError("empty section header") - current = data.setdefault(section, {}) - if not isinstance(current, dict): - raise ValueError(f"invalid section: {section}") - continue - if "=" not in stripped: - raise ValueError(f"invalid line: {line}") - key, value = stripped.split("=", 1) - key = key.strip() - if not key: - raise ValueError("missing key") - value = strip_inline_comment(value.strip()) - target = current if current is not None else data - target[key] = parse_toml_value(value) - return data - - -def normalize_path_config_strings(raw: str) -> str: - normalized_lines: list[str] = [] - for line in raw.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#") or "=" not in line: - normalized_lines.append(line) - continue - - key_part, value_part = line.split("=", 1) - key = key_part.strip() - if key not in PATH_CONFIG_KEYS: - normalized_lines.append(line) - continue - - value = strip_inline_comment(value_part.strip()) - if len(value) < 2 or value[0] != '"' or value[-1] != '"' or "\\" not in value[1:-1]: - normalized_lines.append(line) - continue - - inner = value[1:-1] - has_lone_backslash = False - probe_idx = 0 - while probe_idx < len(inner): - if inner[probe_idx] != "\\": - probe_idx += 1 - continue - if probe_idx + 1 < len(inner) and inner[probe_idx + 1] == "\\": - probe_idx += 2 - continue - has_lone_backslash = True - break - if not has_lone_backslash: - normalized_lines.append(line) - continue - - escaped: list[str] = [] - idx = 0 - while idx < len(inner): - ch = inner[idx] - if ch != "\\": - escaped.append(ch) - idx += 1 - continue - if idx + 1 < len(inner) and inner[idx + 1] == "\\": - escaped.extend(["\\", "\\"]) - idx += 2 - continue - escaped.extend(["\\", "\\"]) - idx += 1 - normalized_lines.append(f'{key_part}= "{"".join(escaped)}"') - - suffix = "\n" if raw.endswith("\n") else "" - return "\n".join(normalized_lines) + suffix - - def load_config(path: Path) -> dict: - raw = normalize_path_config_strings(path.read_text(encoding="utf-8")) - if tomllib is not None: - return tomllib.loads(raw) - return loads_toml_minimal(raw) + return tomllib.loads(path.read_text(encoding="utf-8")) def log(message: str) -> None: @@ -300,7 +90,6 @@ def config_uses_playbook_root(config: dict) -> bool: for key in ( "sync_rules", "sync_memory_bank", - "sync_prompts", "sync_standards", "install_skills", ): @@ -309,34 +98,12 @@ def config_uses_playbook_root(config: dict) -> bool: return False -def validate_removed_config(config: dict) -> None: - if "vendor" in config: - raise ValueError( - "[vendor] is no longer supported; use [playbook].install_mode = " - '"snapshot" and configure languages in [sync_standards]' - ) - +def resolve_install_mode(config: dict) -> str: playbook_config = config.get("playbook", {}) if not isinstance(playbook_config, dict): raise ValueError("[playbook] must be a table") - - if "deploy_root" in playbook_config: - raise ValueError( - "playbook.deploy_root is no longer supported; use playbook.playbook_root" - ) - if "intall_mode" in playbook_config: - raise ValueError( - "playbook.intall_mode is not supported; use playbook.install_mode" - ) - - -def resolve_install_mode(config: dict) -> str: - playbook_config = config.get("playbook", {}) raw = "subtree" - if ( - isinstance(playbook_config, dict) - and playbook_config.get("install_mode") is not None - ): + if playbook_config.get("install_mode") is not None: raw = playbook_config.get("install_mode") mode = str(raw).strip().lower() if mode not in ("subtree", "snapshot"): @@ -458,7 +225,7 @@ def write_snapshot_readme( f"python {scripts_path} -config playbook.toml", "```", "", - f"配置示例:`{join_playbook_subpath(playbook_root, 'playbook.toml.example')}`", + f"配置示例:`{join_playbook_subpath(playbook_root, 'playbook.example.toml')}`", "", "文档入口:", "", @@ -600,9 +367,9 @@ def install_snapshot(config: dict, context: dict) -> int: if templates_src.is_dir(): copytree(templates_src, dest_prefix / "templates" / lang) - example_config = PLAYBOOK_ROOT / "playbook.toml.example" + example_config = PLAYBOOK_ROOT / "playbook.example.toml" if example_config.is_file(): - copy2(example_config, dest_prefix / "playbook.toml.example") + copy2(example_config, dest_prefix / "playbook.example.toml") write_docs_index(dest_prefix, langs) write_snapshot_readme(dest_prefix, playbook_root, langs) @@ -680,6 +447,13 @@ def resolve_template_target( return target_root / rel +def format_sync_counts(label: str, written: int, skipped: int, force: bool) -> str: + message = f"Synced: {label} (written={written}, skipped={skipped})" + if skipped and not force: + message += " Existing files kept; use force to overwrite." + return message + + def sync_directory( template_dir: Path, target_dir: Path, @@ -689,7 +463,9 @@ def sync_directory( playbook_root: str | None, force: bool, no_backup: bool, -) -> None: +) -> tuple[int, int]: + written = 0 + skipped = 0 for template_file in template_dir.rglob("*"): if not template_file.is_file(): continue @@ -699,6 +475,7 @@ def sync_directory( ensure_dir(target_file.parent) if target_file.exists(): if not force: + skipped += 1 continue backup_path(target_file, no_backup) copy2(template_file, target_file) @@ -709,6 +486,8 @@ def sync_directory( playbook_scripts, playbook_root, ) + written += 1 + return written, skipped def extract_block_lines(text: str, start: str, end: str) -> list[str]: @@ -727,6 +506,30 @@ def extract_block_lines(text: str, start: str, end: str) -> list[str]: return block +_AGENTS_BLOCK_START = "<!-- playbook:agents:start -->" +_AGENTS_BLOCK_END = "<!-- playbook:agents:end -->" + + +def preserve_agents_subblock(block: list[str], agents_text: str) -> list[str]: + existing = extract_block_lines(agents_text, _AGENTS_BLOCK_START, _AGENTS_BLOCK_END) + if not existing: + return block + + start_index: int | None = None + end_index: int | None = None + for index, line in enumerate(block): + stripped = line.strip() + if stripped == _AGENTS_BLOCK_START: + start_index = index + elif stripped == _AGENTS_BLOCK_END and start_index is not None: + end_index = index + break + if start_index is None or end_index is None: + return block + + return block[:start_index] + existing + block[end_index + 1 :] + + def update_agents_section( agents_path: Path, template_path: Path, @@ -747,12 +550,15 @@ def update_agents_section( return if not agents_path.exists(): - agents_path.write_text(template_text + "\n", encoding="utf-8", newline="\n") + agents_path.write_text( + template_text.rstrip("\n") + "\n", encoding="utf-8", newline="\n" + ) log("Created: AGENTS.md") return agents_text = agents_path.read_text(encoding="utf-8") if start_marker in agents_text: + block = preserve_agents_subblock(block, agents_text) lines = agents_text.splitlines() updated: list[str] = [] in_block = False @@ -773,9 +579,6 @@ def update_agents_section( ) log("Updated: AGENTS.md (section)") else: - if ".agents/index.md" in agents_text: - log("Skip: AGENTS.md already references .agents/index.md") - return updated = agents_text.rstrip("\n") + "\n\n" + "\n".join(block) + "\n" agents_path.write_text(updated, encoding="utf-8", newline="\n") log("Appended: AGENTS.md (section)") @@ -796,7 +599,7 @@ def resolve_project_name(context: dict) -> str | None: def resolve_template_date(context: dict) -> str: config = context.get("config", {}) if isinstance(config, dict): - for key in ("sync_rules", "sync_memory_bank", "sync_prompts"): + for key in ("sync_rules", "sync_memory_bank"): section = config.get(key) if isinstance(section, dict): value = section.get("date") @@ -821,27 +624,11 @@ def sync_agents_template(context: dict) -> int: playbook_root = resolve_playbook_root(context) date_value = resolve_template_date(context) - agents_dst = project_root / "AGENTS.md" - if agents_dst.exists(): - agents_text = agents_dst.read_text(encoding="utf-8") - if "<!-- playbook:framework:start -->" in agents_text: - start_marker = "<!-- playbook:framework:start -->" - end_marker = "<!-- playbook:framework:end -->" - elif "<!-- playbook:templates:start -->" in agents_text: - start_marker = "<!-- playbook:templates:start -->" - end_marker = "<!-- playbook:templates:end -->" - else: - start_marker = "<!-- playbook:templates:start -->" - end_marker = "<!-- playbook:templates:end -->" - else: - start_marker = "<!-- playbook:framework:start -->" - end_marker = "<!-- playbook:framework:end -->" - update_agents_section( - agents_dst, + project_root / "AGENTS.md", agents_src, - start_marker, - end_marker, + "<!-- playbook:framework:start -->", + "<!-- playbook:framework:end -->", project_name, date_value, playbook_scripts, @@ -927,8 +714,6 @@ def sync_claude_md(project_root: Path, config: dict) -> None: updated_text = "# CLAUDE.md\n\n" + updated_text.lstrip() claude_md.write_text(updated_text, encoding="utf-8", newline="\n") log("Updated CLAUDE.md (playbook block).") - elif "@AGENTS.md" in text: - log("Skip: CLAUDE.md already references AGENTS.md") else: appended = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n" claude_md.write_text(appended, encoding="utf-8", newline="\n") @@ -936,7 +721,7 @@ def sync_claude_md(project_root: Path, config: dict) -> None: def should_sync_agents(config: dict) -> bool: - for key in ("sync_rules", "sync_memory_bank", "sync_prompts", "sync_standards"): + for key in ("sync_rules", "sync_memory_bank", "sync_standards"): if key in config: return True return False @@ -1015,7 +800,7 @@ def sync_memory_bank_action(config: dict, context: dict) -> int: memory_dst = project_root / "memory-bank" ensure_dir(memory_dst) - sync_directory( + written, skipped = sync_directory( memory_src, memory_dst, project_name, @@ -1025,43 +810,7 @@ def sync_memory_bank_action(config: dict, context: dict) -> int: force, no_backup, ) - log("Synced: memory-bank/") - return 0 - - -def sync_prompts_action(config: dict, context: dict) -> int: - project_root: Path = context["project_root"] - if project_root.resolve() == PLAYBOOK_ROOT.resolve(): - log("Skip: playbook root equals project root.") - return 0 - - templates_dir = PLAYBOOK_ROOT / "templates" - prompts_src = templates_dir / "prompts" - if not prompts_src.is_dir(): - print(f"ERROR: templates not found: {prompts_src}", file=sys.stderr) - return 2 - - project_name = resolve_project_name(context) - playbook_scripts = resolve_playbook_scripts(context) - playbook_root = resolve_playbook_root(context) - date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d") - force = bool(config.get("force", False)) - no_backup = bool(config.get("no_backup", False)) - - prompts_dst = project_root / "docs/prompts" - ensure_dir(prompts_dst.parent) - ensure_dir(prompts_dst) - sync_directory( - prompts_src, - prompts_dst, - project_name, - date_value, - playbook_scripts, - playbook_root, - force, - no_backup, - ) - log("Synced: docs/prompts/") + log(format_sync_counts("memory-bank/", written, skipped, force)) return 0 @@ -1075,6 +824,7 @@ def render_agents_block(langs: list[str]) -> list[str]: "", "- 入口:`.agents/index.md`", f"- 语言规则:{langs_line}" if langs_line else "- 语言规则:", + "", "<!-- playbook:agents:end -->", ] return lines @@ -1109,9 +859,6 @@ def update_agents_block(agents_md: Path, block_lines: list[str]) -> None: agents_md.write_text("\n".join(updated) + "\n", encoding="utf-8", newline="\n") log("Updated AGENTS.md (playbook block).") else: - if ".agents/index.md" in text: - log("Skip: AGENTS.md already references .agents/index.md") - return updated = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n" agents_md.write_text(updated, encoding="utf-8", newline="\n") log("Appended playbook block to AGENTS.md") @@ -1234,8 +981,6 @@ def sync_gitattributes_append( def sync_gitattributes_block(src: Path, dst: Path, no_backup: bool) -> None: begin = "# BEGIN playbook .gitattributes" end = "# END playbook .gitattributes" - begin_old = "# BEGIN tsl-playbook .gitattributes" - end_old = "# END tsl-playbook .gitattributes" src_lines = src.read_text(encoding="utf-8").splitlines() block_lines = [begin] + src_lines + [end] @@ -1246,14 +991,14 @@ def sync_gitattributes_block(src: Path, dst: Path, no_backup: bool) -> None: in_block = False replaced = False for line in original: - if line == begin or line == begin_old: + if line == begin: if not replaced: updated.extend(block_lines) replaced = True in_block = True continue if in_block: - if line == end or line == end_old: + if line == end: in_block = False continue updated.append(line) @@ -1369,9 +1114,6 @@ def normalize_globs(raw: object) -> list[str]: def install_skills_action(config: dict, context: dict) -> int: mode = str(config.get("mode", "list")).lower() - if "codex_home" in config: - print("ERROR: codex_home is no longer supported; use agents_home", file=sys.stderr) - return 2 agents_home = Path(config.get("agents_home", "~/.agents")).expanduser() if not agents_home.is_absolute(): agents_home = (context["project_root"] / agents_home).resolve() @@ -1521,8 +1263,6 @@ def run_action(name: str, config: dict, context: dict) -> int: return sync_rules_action(config, context) if name == "sync_memory_bank": return sync_memory_bank_action(config, context) - if name == "sync_prompts": - return sync_prompts_action(config, context) if name == "sync_standards": return sync_standards_action(config, context) if name == "install_skills": @@ -1533,47 +1273,22 @@ def run_action(name: str, config: dict, context: dict) -> int: def main(argv: list[str]) -> int: - if "-h" in argv or "-help" in argv: - print(usage()) - return 0 + parser = argparse.ArgumentParser(prog="playbook.py") + parser.add_argument("-config", dest="config_path", required=True, metavar="PATH") + args = parser.parse_args(argv) - spec_path = parse_cli_value(argv, "-record-spec") - if spec_path is not None: - print( - "ERROR: -record-spec has been removed; spec files are the record.", - file=sys.stderr, - ) - return 2 - - plan_path = parse_cli_value(argv, "-record-plan") - if plan_path is not None: - progress_path = parse_cli_value(argv, "-progress") - if not progress_path: - print("ERROR: -progress is required.\n" + usage(), file=sys.stderr) - return 2 - code, message = MAIN_LOOP.record_plan(Path(progress_path), plan_path) - if code != 0: - print(message, file=sys.stderr) - return code - print(message) - return 0 - - if "-config" not in argv: - print("ERROR: -config is required.\n" + usage(), file=sys.stderr) - return 2 - idx = argv.index("-config") - if idx + 1 >= len(argv) or not argv[idx + 1]: - print("ERROR: -config requires a path.\n" + usage(), file=sys.stderr) - return 2 - - config_path = Path(argv[idx + 1]).expanduser() + config_path = Path(args.config_path).expanduser() if not config_path.is_file(): print(f"ERROR: config not found: {config_path}", file=sys.stderr) return 2 - config = load_config(config_path) try: - validate_removed_config(config) + config = load_config(config_path) + except tomllib.TOMLDecodeError as exc: + print(f"ERROR: invalid TOML in {config_path}: {exc}", file=sys.stderr) + return 2 + + try: install_mode = resolve_install_mode(config) except ValueError as exc: print(f"ERROR: {exc}", file=sys.stderr) diff --git a/skills/README.md b/skills/README.md index 154d4ab2..7ba60667 100644 --- a/skills/README.md +++ b/skills/README.md @@ -9,35 +9,32 @@ ## 一方 Skills -| Skill | 作用 | 典型场景 | -| ------------------------ | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `commit-message` | 根据 staged diff 生成符合仓库规范的提交信息,并判断是否应拆分提交 | 写 commit message、检查 staged 改动是否适合一个提交 | -| `gitea-fix-ci` | 基于 Gitea Actions run/job/log 诊断失败 CI,先形成修复计划再改代码;含 `fetch_ci_logs.py` 取证脚本 | Gitea PR checks 失败、远端 CI 红但本地需要定位 | -| `tsl-syntax-reference` | 查询 TSL 语法条目,验证具体写法和错误边界;不负责 API、命名、风格、工具链或模块集成 | 写/改/审 TSL 时确认语言结构、表达式、控制流、对象模型和语法限制 | -| `tsl-api-reference` | 查询随 skill 分发的 TSL API 参考:按名精确查条目,或按中文关键词发现候选 | 写/审 TSL 时确认 builtin、dotnet、模块 API 的签名、参数、返回值和示例 | +| Skill | 作用 | 典型场景 | +| ---------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `commit-message` | 根据 staged diff 生成符合仓库规范的提交信息,并判断是否应拆分提交 | 写 commit message、检查 staged 改动是否适合一个提交 | +| `gitea-fix-ci` | 基于 Gitea Actions run/job/log 诊断失败 CI,先形成修复计划再改代码;含 `fetch_ci_logs.py` 取证脚本 | Gitea PR checks 失败、远端 CI 红但本地需要定位 | +| `tsl-syntax-reference` | 查询 TSL 语法条目,验证具体写法和错误边界;不负责 API、命名、风格、工具链或模块集成 | 写/改/审 TSL 时确认语言结构、表达式、控制流、对象模型和语法限制 | +| `tsl-api-reference` | 查询随 skill 分发的 TSL API 参考:按名精确查条目,或按中文关键词发现候选 | 写/审 TSL 时确认 builtin、dotnet、模块 API 的签名、参数、返回值和示例 | ## 第三方 Skills -### Superpowers Workflow Suite +### Matt Pocock Engineering Workflow -这些 skill 来自 `superpowers`,主要提供计划、执行、调试、验证和审查工作流。 +正式工程主链来自 `mattpocock/skills`,使用本地 Markdown tracker 和 ticket DAG。 -| Skill | 作用 | -| -------------------------------- | ------------------------------------------------ | -| `using-superpowers` | 会话开始时判断并加载适用 skill | -| `brainstorming` | 需求澄清、方案讨论、设计文档产出 | -| `writing-plans` | 把设计或需求拆成可执行 implementation plan | -| `executing-plans` | 按已写好的 plan 执行任务 | -| `subagent-driven-development` | 用子 agent 按任务执行、审查和迭代 plan | -| `dispatching-parallel-agents` | 多个互不依赖问题并行分派 | -| `using-git-worktrees` | 需要隔离工作区时创建或使用 git worktree | -| `finishing-a-development-branch` | 开发分支完成后的合并、PR、保留或丢弃流程 | -| `test-driven-development` | 新功能、bugfix、重构前的 TDD 流程 | -| `systematic-debugging` | bug、测试失败、异常行为的根因定位流程 | -| `verification-before-completion` | 声称完成、修复或通过前的验证门禁 | -| `requesting-code-review` | 主动请求代码审查,通常用于任务完成或合并前 | -| `receiving-code-review` | 处理别人给出的 review 意见,判断采纳、反驳或澄清 | -| `writing-skills` | 创建、修改或验证 skill 的写作规范 | +| Skill | 作用 | +| ------------------------------ | ---------------------------------------------- | +| `setup-matt-pocock-skills` | 配置 tracker、领域文档和 agent 入口 | +| `grill-with-docs` / `grilling` | 收敛 design tree 并同步领域词汇/ADR | +| `to-spec` | 把已确认上下文合成为 feature spec | +| `to-tickets` | 拆成带 blocking edges 的 tracer-bullet tickets | +| `implement` / `tdd` | 在约定 seam 上按 ticket 实现和验证 | +| `code-review` | 对固定点后的提交做 Standards/Spec 双轴审查 | +| `handoff` | 跨 session 交接未完成上下文 | + +安装时使用 `mode = "all"`,或在 `skills` 中逐项列出主链及其依赖;安装器不推导 +skill 依赖。正式流程的调度、branch、worktree 和集成门禁由 `scripts/main_loop.py` +管理。 ### brooks-lint Suite diff --git a/skills/thirdparty/.sources/matt-pocock-skills.list b/skills/thirdparty/.sources/matt-pocock-skills.list new file mode 100644 index 00000000..8c4a5879 --- /dev/null +++ b/skills/thirdparty/.sources/matt-pocock-skills.list @@ -0,0 +1,29 @@ +ask-matt +code-review +codebase-design +diagnosing-bugs +domain-modeling +git-guardrails-claude-code +grill-me +grill-with-docs +grilling +handoff +implement +improve-codebase-architecture +migrate-to-shoehorn +prototype +research +resolving-merge-conflicts +scaffold-exercises +setup-matt-pocock-skills +setup-pre-commit +tdd +teach +to-questionnaire +to-spec +to-tickets +triage +wait-what +wayfinder +wizard +writing-for-agents diff --git a/skills/thirdparty/.sources/superpowers.list b/skills/thirdparty/.sources/superpowers.list deleted file mode 100644 index 7225a0f0..00000000 --- a/skills/thirdparty/.sources/superpowers.list +++ /dev/null @@ -1,14 +0,0 @@ -brainstorming -dispatching-parallel-agents -executing-plans -finishing-a-development-branch -receiving-code-review -requesting-code-review -subagent-driven-development -systematic-debugging -test-driven-development -using-git-worktrees -using-superpowers -verification-before-completion -writing-plans -writing-skills diff --git a/skills/thirdparty/ask-matt/PHASE-BOUNDARIES.md b/skills/thirdparty/ask-matt/PHASE-BOUNDARIES.md new file mode 100644 index 00000000..cb31e6ab --- /dev/null +++ b/skills/thirdparty/ask-matt/PHASE-BOUNDARIES.md @@ -0,0 +1,55 @@ +# Phase boundaries + +A **phase** is a chunk of work inside a session — the grilling, the implementation, the QA. The definition is fuzzy on purpose: a phase ends when you think *"ok, we're done with that"*. + +The **phase boundary** is the gap between two phases, and it is the only place this decision belongs. Mid-phase there is no decision to make — continue, or split the work that's left into subagents. Compacting mid-phase makes the agent lose the thread. + +## The five options + +| Option | What it does | +| ------------ | --------------------------------------------------------------- | +| **Continue** | Stay in the session. No context switch at all. | +| **`/clear`** | Empty the context window and start from nothing. | +| **`/handoff`** | Write a portable markdown file and seed a session anywhere with it. | +| **Subagent** | Send the task to its own context window and get a report back. | +| **`/compact`** | Compress this context and seed a fresh session with the summary. | + +## The tree + +Work top to bottom at the boundary. The first **yes** wins. + +**1. Can you continue in this session?** Two things make the answer yes: the next phase needs this phase as a **primary source**, or you have enough [smart zone](https://www.aihero.dev/ai-coding-dictionary/smart-zone) left (~150k tokens) for the next phase to fit. Grilling → implementation is the standard yes: the implementation wants the reasoning verbatim, not a summary of it. Continue costs nothing and loses nothing, so rule it out before anything else. + +**2. Is the context irrelevant to what comes next?** Is everything in this session — the exploration, the decisions, the dead ends — disposable? If so, **`/clear`**. It is the cheapest move on the board: it takes no time and hands back the whole window. `/clear` also isn't terminal — the old session stays resumable. + +The cost of getting this wrong is one-way. Clear a *relevant* context and you lose the **why** behind what you built, and no amount of reading the diff back gets it returned. + +**3. Do you need to hand off?** `/handoff` is narrow. You need it only when you are: + +- swapping to a **new harness** (Claude → Codex), +- moving to a **new directory** or repo, +- sending the work to a **colleague**, +- or forking a side task you found **mid-phase** without derailing what you're doing. + +That list is the whole clause. What `/handoff` buys is **portability** — a file that travels. If nothing is travelling, you don't need it. + +**4. Can the task be done AFK?** Is it scoped tightly enough to run with you away from the keyboard, no steering? Then send it to a **subagent** and leave this session untouched. Automated review is the standard case: the agent reads the diff and reports, and you aren't needed while it does. + +**5. Otherwise, `/compact`.** Relevant context, same harness, same directory, and you need to stay in the loop — this is where the tree lands, and it lands here often. Pass it an instruction (`/compact we're going to QA this area`) so the summary keeps what the next phase needs. + +`/compact` is the **default, not the first reach**. It sits at the bottom because the four questions above it are all cheaper or more precise. The failure mode when people start here is a fresh session that is confidently wrong about a decision the summary flattened. + +## Primary and secondary sources + +Every move except **Continue** turns a **primary source** into a **secondary source** — the session as it happened, replaced by a summary of it. The trade is always the same shape: + +| Source | Information | Noise | Room to move | +| --------------------------------- | ----------- | ----- | ------------ | +| Primary (Continue) | Full | Lots | Little | +| Secondary (`/compact`, `/handoff`) | Lossy | Less | Lots | + +This is why question 1 comes first. You only pay the lossiness when staying costs more than it saves. + +## These are judgement calls + +The questions are not objective — each has taste in it, and the same boundary can go two ways on two days. The value is in asking them **in order**, at the boundary rather than in the middle of the work. diff --git a/skills/thirdparty/ask-matt/SKILL.md b/skills/thirdparty/ask-matt/SKILL.md new file mode 100644 index 00000000..7f3ab782 --- /dev/null +++ b/skills/thirdparty/ask-matt/SKILL.md @@ -0,0 +1,90 @@ +--- +name: ask-matt +description: Ask which skill or flow fits your situation. A router over the skills in this repo. +disable-model-invocation: true +--- + +# Ask Matt + +You don't remember every skill, so ask. + +A **flow** is a path through the skills. Most paths run along one **main flow**, and two **on-ramps** merge onto it. Everything else is standalone, or a vocabulary layer that runs underneath. + +## The main flow: idea → ship + +The route most work travels. You have an idea and want it built. + +1. **`/grill-with-docs`** — sharpen the idea by interview. Start here whenever you are **working in a working directory**: it's stateful, retaining what it learns in `CONTEXT.md` and ADRs. (No working directory? Use `/grill-me` — see Standalone. Both run the same `/grilling` primitive; `grill-with-docs` is the one that leaves a paper trail, which makes it the better of the two whenever a repo is there to leave it in.) +2. **Branch — can you settle every question in conversation?** If a question needs a runnable answer (state, business logic, a UI you have to see), detour through a prototype, bridged by **`/handoff`** in both directions (a prototype lives in its own directory, which is exactly what `/handoff` is for — see Phase boundaries): + - **`/handoff`** out, then open a fresh session against that file, + - **`/prototype`** to answer the question with throwaway code, + - **`/handoff`** back what you learned, and reference it from the original idea thread. +3. **Branch — is this a multi-session build?** + - **Yes** → **`/to-spec`** (turn the thread into a spec), then **`/to-tickets`** to split it into tracer-bullet tickets, each declaring its **blocking edges**. On a local tracker that's one file per ticket under `.scratch/<feature>/issues/`, worked blockers-first by hand; on a real tracker the edges become native blocking links, so any ticket whose blockers are done can be grabbed — kick off **`/implement`** per ticket, **`/clear`ing context between each one**. Each ticket is self-contained, so the last one's context is disposable. + - **No** → **`/implement`** right here, in the same context window. + + Either way, **`/implement`** builds each issue by driving **`/tdd`** internally — one red-green slice at a time — then closes out by running **`/code-review`**, a two-axis review (Standards + Spec) of the diff, before committing. Reach for **`/tdd`** on its own when you just want to build a concrete behaviour test-first without a full spec, and **`/code-review`** on its own whenever you want to review a branch or PR against a fixed point. + +### Context hygiene + +Keep steps 1–3 in **one unbroken context window** — don't compact or clear until after `/to-tickets` — so the grilling, spec, and tickets all build on the same thinking. Each `/implement` then starts fresh, working from the ticket. + +The limit on this is the **[smart zone](https://www.aihero.dev/ai-coding-dictionary/smart-zone)**: the window (~150k tokens on state-of-the-art models) within which the model still reasons sharply. If a session approaches it before `/to-tickets`, don't push on degraded — `/compact` at the nearest phase boundary and carry on (see Phase boundaries). + +## On-ramps + +A starting situation that generates work, then merges onto the main flow. + +- **Bugs and requests piling up** → **`/triage`**. It moves issues through triage roles and produces agent-ready issues, which **`/implement`** later picks up. + + Triage is only for issues **you didn't create** — bug reports, incoming feature requests, anything that arrives raw. Tickets that `/to-tickets` produced are already agent-ready, so **don't triage them**. + +- **Something's broken** → **`/diagnosing-bugs`**. For the hard ones: the bug that resists a first glance, the intermittent flake, the regression that crept in between two known-good states. It refuses to theorise until it has a **tight feedback loop** — one command that already goes red on *this* bug — then fixes with a regression test. Its post-mortem hands off to **`/improve-codebase-architecture`** when the real finding is that there's no good seam to lock the bug down. + +- **A huge, foggy effort — a greenfield project or a huge feature build, too big for one session** → **`/wayfinder`**, the most cognitively demanding flow here. When the way from here to the destination isn't visible yet, it charts a **shared map** of **decision tickets** on the issue tracker and resolves them one at a time — producing **decisions, not deliverables** — until the fog is pushed back and the way is clear. Where **`/grill-with-docs`** sharpens an idea you can hold in one session, wayfinder is for the idea you can't — and it's slower and denser, so save it for exactly that, never a well-scoped feature. + + When the map clears, **it hands off, it doesn't build**: merge onto the main flow at **`/to-spec`**, which collapses the map's linked decisions into a buildable plan, then `/to-tickets` and `/implement` as usual. Looping the map straight into `/implement` skips that collapse and throws the linked detail away — go straight to `/implement` only when the effort turned out genuinely small. + +## Codebase health + +Not feature work — upkeep. + +- **`/improve-codebase-architecture`** — run whenever you have a spare moment to keep the codebase good for agents to operate in. It surfaces **deepening opportunities**; picking one _generates an idea_ you can take into the main flow at `/grill-with-docs`. It's the survey that finds the candidates; **`/codebase-design`** (below) is the bench you design the chosen one on. + +## Vocabulary underneath + +Two model-invoked references that run *beneath* the other skills — each the single source of truth for its vocabulary. Reach for them directly when the **words**, not the process, are the problem; or let the skills above pull them in. + +- **`/domain-modeling`** — sharpen the project's *domain* language: challenge a fuzzy term, resolve an overloaded word ("account" doing three jobs), record a hard-to-reverse decision as an ADR. It's the active discipline `/grill-with-docs` drives to keep `CONTEXT.md` a clean glossary. +- **`/codebase-design`** — the deep-module vocabulary (module, interface, depth, seam, adapter, leverage, locality) for designing a module's *shape*: a lot of behaviour behind a small interface at a clean seam. `/tdd` and `/improve-codebase-architecture` both speak it. + +## Phase boundaries + +A **phase** is a chunk of work inside a session — the grilling, the implementation, the QA. At the **boundary** between two of them you have five options, and picking between them is the fuzziest decision in this whole map: + +- **Continue** — stay put. Costs nothing, loses nothing. +- **`/clear`** — empty the window, when nothing here matters to what's next. +- **`/handoff`** — write a portable markdown file. Narrow: only for a **new harness**, a **new directory**, a **colleague**, or forking a side task **mid-phase**. What it buys is portability. +- **Subagent** — send a tightly-scoped task to its own window and get a report back. +- **`/compact`** — compress this context and seed a fresh session with it. The **default**, at the bottom of the tree rather than the first reach. + +Read [PHASE-BOUNDARIES.md](PHASE-BOUNDARIES.md) for the ordered tree — the five questions, the reasoning behind each branch, and why the primary-source cost makes **Continue** the one to rule out first. Make the decision **at** a boundary; mid-phase, continue or split the rest into subagents. + +## Standalone + +Off the main flow entirely. + +- **`/grill-me`** — the same relentless interview as `/grill-with-docs`, but **stateless**: it saves nothing locally and builds no `CONTEXT.md`. Reach for it when you are **not working in a working directory** — sharpening a plan, a design, a piece of writing, anything with no repo under it. If you are in a working directory, use `/grill-with-docs` instead: it runs the same interview and leaves a paper trail, so it is strictly the better one. +- **`/grilling`** — the interview primitive itself: rounds, the frontier, facts are the agent's job and decisions are yours. `/grill-me` and `/grill-with-docs` are the two named ways in, and `/triage`, `/wayfinder` and `/improve-codebase-architecture` all run it internally. Reach for it directly only when you want the interview with no wrapper around it. +- **`/resolving-merge-conflicts`** — work an in-progress merge or rebase conflict hunk by hunk, resolving by **intent** traced to each side's primary source rather than by picking lines, then finish the operation. It never runs `--abort`. Standalone and off every flow: reach for it when you are already mid-conflict. +- **`/prototype`** — a small, throwaway program that answers one design question: does this state model feel right, or what should this UI look like. Throwaway is a constraint on how the code is written, not a promise to destroy it: the answer folds into the real code, and the prototype itself is kept as a **primary source** on a `prototype/<name>` branch out of main, pointed at from the implementation issue. It's the detour in step 2 of the main flow, but reach for it any time a design question is hard to settle on paper. +- **`/research`** — delegate reading legwork to a **background agent**: it investigates a question against **primary sources**, then leaves a cited Markdown file in the repo. Keep working while it reads. The file it produces is something to take *into* the main flow at `/grill-with-docs` — research feeds the thinking, it doesn't replace it. +- **`/to-questionnaire`** — when the thing blocking you isn't in your head or the codebase but in **someone else's**, this writes them a questionnaire to fill in. It's the inverse of `/grill-me`: instead of interviewing you about the subject, it interviews you about the **send** — who it's going to, what you need back — and aims the questions at the gap. What comes back is material for `/grill-with-docs` or `/to-spec`. +- **`/wizard`** — for the steps only a **human** can take: provisioning infrastructure, setting up credentials or CI secrets, clicking through an unfamiliar third-party dashboard, running a one-off migration or cutover. It generates an interactive bash script that opens each URL, captures each value, and writes it into `.env` and GitHub secrets — so the procedure stops being something you re-explain to an agent every time. Model-invoked, so the agent reaches for it the moment it hits a wall only you can pass. If the agent could just do it itself, it should; this is for where a human is genuinely in the loop. +- **`/wait-what`** — the corrective for a message that didn't land. Use it mid-conversation, inside any other skill, and the agent re-pitches what it just said with the context you were missing, in plain English, using the `CONTEXT.md` vocabulary. It works after the fact; `/grill-with-docs` is the upfront cure, because a shared language agreed early is what stops the jargon arriving at all. +- **`/teach`** — learn a concept over multiple sessions, using the current directory as a stateful workspace. +- **`/writing-for-agents`** — reference for writing documents agents consume: skills, AGENTS.md, pointed-at docs. + +## Precondition + +**`/setup-matt-pocock-skills`** — run before your first engineering flow to configure the issue tracker, triage labels, and doc layout the other skills assume. Custom issue trackers also work. diff --git a/skills/thirdparty/ask-matt/agents/openai.yaml b/skills/thirdparty/ask-matt/agents/openai.yaml new file mode 100644 index 00000000..5c60d51b --- /dev/null +++ b/skills/thirdparty/ask-matt/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Ask Matt" + short_description: "Find the right skill or workflow" +policy: + allow_implicit_invocation: false diff --git a/skills/thirdparty/brainstorming/SKILL.md b/skills/thirdparty/brainstorming/SKILL.md deleted file mode 100644 index 789c3a19..00000000 --- a/skills/thirdparty/brainstorming/SKILL.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -name: brainstorming -description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation." ---- - -# Brainstorming Ideas Into Designs - -Help turn ideas into fully formed designs and specs through natural collaborative dialogue. - -Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval. - -<HARD-GATE> -Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity. -</HARD-GATE> - -## Anti-Pattern: "This Is Too Simple To Need A Design" - -Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval. - -## Checklist - -You MUST create a task for each of these items and complete them in order: - -1. **Explore project context** — check files, docs, recent commits -2. **Offer the visual companion just-in-time** — NOT upfront. The first time a question would genuinely be clearer shown than described, offer it then (its own message); on approval its browser tab opens for you. If no visual question ever arises, never offer it. See the Visual Companion section below. -3. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria -4. **Propose 2-3 approaches** — with trade-offs and your recommendation -5. **Present design** — in sections scaled to their complexity, get user approval after each section -6. **Write design doc** — save to `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` and commit -7. **Spec self-review** — quick inline check for placeholders, contradictions, ambiguity, scope (see below) -8. **User reviews written spec** — ask user to review the spec file before proceeding -9. **Transition to implementation** — invoke writing-plans skill to create implementation plan - -## Process Flow - -```dot -digraph brainstorming { - "Explore project context" [shape=box]; - "Ask clarifying questions" [shape=box]; - "Propose 2-3 approaches" [shape=box]; - "Present design sections" [shape=box]; - "User approves design?" [shape=diamond]; - "Write design doc" [shape=box]; - "Spec self-review\n(fix inline)" [shape=box]; - "User reviews spec?" [shape=diamond]; - "Invoke writing-plans skill" [shape=doublecircle]; - - "Explore project context" -> "Ask clarifying questions"; - "Ask clarifying questions" -> "Propose 2-3 approaches"; - "Propose 2-3 approaches" -> "Present design sections"; - "Present design sections" -> "User approves design?"; - "User approves design?" -> "Present design sections" [label="no, revise"]; - "User approves design?" -> "Write design doc" [label="yes"]; - "Write design doc" -> "Spec self-review\n(fix inline)"; - "Spec self-review\n(fix inline)" -> "User reviews spec?"; - "User reviews spec?" -> "Write design doc" [label="changes requested"]; - "User reviews spec?" -> "Invoke writing-plans skill" [label="approved"]; -} -``` - -**The terminal state is invoking writing-plans.** Do NOT invoke frontend-design, mcp-builder, or any other implementation skill. The ONLY skill you invoke after brainstorming is writing-plans. - -## The Process - -**Understanding the idea:** - -- Check out the current project state first (files, docs, recent commits) -- Before asking detailed questions, assess scope: if the request describes multiple independent subsystems (e.g., "build a platform with chat, file storage, billing, and analytics"), flag this immediately. Don't spend questions refining details of a project that needs to be decomposed first. -- If the project is too large for a single spec, help the user decompose into sub-projects: what are the independent pieces, how do they relate, what order should they be built? Then brainstorm the first sub-project through the normal design flow. Each sub-project gets its own spec → plan → implementation cycle. -- For appropriately-scoped projects, ask questions one at a time to refine the idea -- Prefer multiple choice questions when possible, but open-ended is fine too -- Only one question per message - if a topic needs more exploration, break it into multiple questions -- Focus on understanding: purpose, constraints, success criteria - -**Exploring approaches:** - -- Propose 2-3 different approaches with trade-offs -- Present options conversationally with your recommendation and reasoning -- Lead with your recommended option and explain why -- YAGNI ruthlessly - remove unnecessary features from every approach and design - -**Presenting the design:** - -- Once you believe you understand what you're building, present the design -- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced -- Ask after each section whether it looks right so far -- Cover: architecture, components, data flow, error handling, testing -- Be ready to go back and clarify if something doesn't make sense - -**Design for isolation and clarity:** - -- Break the system into smaller units that each have one clear purpose, communicate through well-defined interfaces, and can be understood and tested independently -- For each unit, you should be able to answer: what does it do, how do you use it, and what does it depend on? -- Can someone understand what a unit does without reading its internals? Can you change the internals without breaking consumers? If not, the boundaries need work. -- Smaller, well-bounded units are also easier for you to work with - you reason better about code you can hold in context at once, and your edits are more reliable when files are focused. When a file grows large, that's often a signal that it's doing too much. - -**Working in existing codebases:** - -- Explore the current structure before proposing changes. Follow existing patterns. -- Where existing code has problems that affect the work (e.g., a file that's grown too large, unclear boundaries, tangled responsibilities), include targeted improvements as part of the design - the way a good developer improves code they're working in. -- Don't propose unrelated refactoring. Stay focused on what serves the current goal. - -## After the Design - -**Documentation:** - -- Write the validated design (spec) to `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` - - (User preferences for spec location override this default) -- Use elements-of-style:writing-clearly-and-concisely skill if available -- Commit the design document to git - -**Spec Self-Review:** -After writing the spec document, look at it with fresh eyes: - -1. **Placeholder scan:** Any "TBD", "TODO", incomplete sections, or vague requirements? Fix them. -2. **Internal consistency:** Do any sections contradict each other? Does the architecture match the feature descriptions? -3. **Scope check:** Is this focused enough for a single implementation plan, or does it need decomposition? -4. **Ambiguity check:** Could any requirement be interpreted two different ways? If so, pick one and make it explicit. - -Fix any issues inline. No need to re-review — just fix and move on. - -**User Review Gate:** -After the spec review loop passes, ask the user to review the written spec before proceeding: - -> "Spec written and committed to `<path>`. Please review it and let me know if you want to make any changes before we start writing out the implementation plan." - -Wait for the user's response. If they request changes, make them and re-run the spec review loop. Only proceed once the user approves. - -**Implementation:** - -- Invoke the writing-plans skill to create a detailed implementation plan -- Do NOT invoke any other skill. writing-plans is the next step. - -## Visual Companion - -A browser-based companion for showing mockups, diagrams, and visual options during brainstorming. Available as a tool — not a mode. Accepting the companion means it's available for questions that benefit from visual treatment; it does NOT mean every question goes through the browser. - -**Offering the companion (just-in-time):** Do NOT offer it upfront. Wait until a question would genuinely be clearer shown than told — a real mockup / layout / diagram question, not merely a UI *topic*. The first time that happens, offer it then, as its own message: -> "This next part might be easier if I show you — I can put together mockups, diagrams, and comparisons in a browser tab as we go. It's still new and can be token-intensive. Want me to? I'll open it for you." - -**This offer MUST be its own message.** Only the offer — no clarifying question, summary, or other content. Wait for the user's response. If they accept, start the server with `--open` so their browser opens to the first screen automatically. If they decline, continue text-only and don't offer again unless they raise it. - -**Per-question decision:** Even after the user accepts, decide FOR EACH QUESTION whether to use the browser or the terminal. The test: **would the user understand this better by seeing it than reading it?** - -- **Use the browser** for content that IS visual — mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs -- **Use the terminal** for content that is text — requirements questions, conceptual choices, tradeoff lists, A/B/C/D text options, scope decisions - -A question about a UI topic is not automatically a visual question. "What does personality mean in this context?" is a conceptual question — use the terminal. "Which wizard layout works better?" is a visual question — use the browser. - -If they agree to the companion, read the detailed guide before proceeding: -`skills/brainstorming/visual-companion.md` diff --git a/skills/thirdparty/brainstorming/scripts/frame-template.html b/skills/thirdparty/brainstorming/scripts/frame-template.html deleted file mode 100644 index f540bb8a..00000000 --- a/skills/thirdparty/brainstorming/scripts/frame-template.html +++ /dev/null @@ -1,213 +0,0 @@ -<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8"> - <title>Superpowers Brainstorming - - - -
- -
Connecting…
-
- -
-
- -
-
- - - diff --git a/skills/thirdparty/brainstorming/scripts/helper.js b/skills/thirdparty/brainstorming/scripts/helper.js deleted file mode 100644 index e11d2648..00000000 --- a/skills/thirdparty/brainstorming/scripts/helper.js +++ /dev/null @@ -1,167 +0,0 @@ -(function() { - const MIN_RECONNECT_MS = 500; - const MAX_RECONNECT_MS = 30000; - const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected - - // Pure: next backoff delay (doubles, capped). Exported for unit tests. - function nextReconnectDelay(current, max) { - return Math.min(current * 2, max); - } - if (typeof module !== 'undefined' && module.exports) { - module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS }; - } - - // Everything below is browser-only; bail out when loaded in Node (tests). - if (typeof window === 'undefined') return; - - let ws = null; - let eventQueue = []; - let reconnectDelay = MIN_RECONNECT_MS; - let reconnectTimer = null; - let disconnectedSince = null; - let everConnected = false; - let tombstoneShown = false; - - function sessionKey() { - try { - return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key'); - } catch (e) {} - return null; - } - - function websocketUrl() { - const key = sessionKey(); - return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : ''); - } - - function reloadAfterRecovery() { - const key = sessionKey(); - if (key) { - window.location.replace('/?key=' + encodeURIComponent(key)); - } else { - window.location.reload(); - } - } - - // Reflect connection state in the frame's status pill (absent on full-doc screens). - function setStatus(state) { - const el = document.querySelector('.status'); - if (!el) return; - const map = { - connecting: ['Connecting…', 'var(--text-tertiary)'], - connected: ['Connected', 'var(--success)'], - reconnecting: ['Reconnecting…', 'var(--warning)'], - disconnected: ['Disconnected', 'var(--error)'] - }; - const [text, color] = map[state] || map.disconnected; - el.textContent = text; - el.style.setProperty('--status-color', color); - } - - // Self-styled so it works on framed and full-document screens alike. - function showTombstone() { - if (tombstoneShown) return; - tombstoneShown = true; - const el = document.createElement('div'); - el.id = 'bs-tombstone'; - el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' + - 'align-items:center;justify-content:center;padding:2rem;text-align:center;' + - 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif'; - el.innerHTML = '
' + - '

Companion paused

' + - '

This brainstorm companion has stopped. ' + - 'Ask your coding agent to bring it back — this page reconnects automatically.

'; - if (document.body) document.body.appendChild(el); - } - - function connect() { - if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } - setStatus(everConnected ? 'reconnecting' : 'connecting'); - ws = new WebSocket(websocketUrl()); - - ws.onopen = () => { - const recovered = tombstoneShown; - everConnected = true; - disconnectedSince = null; - reconnectDelay = MIN_RECONNECT_MS; - tombstoneShown = false; - setStatus('connected'); - eventQueue.forEach(e => ws.send(JSON.stringify(e))); - eventQueue = []; - // Recovered from a tombstoned outage (e.g. the server restarted on the same - // port) — reload through the keyed bootstrap when possible so the cookie is - // refreshed before the visible URL returns to bare /. - if (recovered) reloadAfterRecovery(); - }; - - ws.onmessage = (msg) => { - let data; - try { data = JSON.parse(msg.data); } catch (e) { return; } - if (data.type === 'reload') window.location.reload(); - }; - - ws.onclose = () => { - ws = null; - if (disconnectedSince === null) disconnectedSince = Date.now(); - if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) { - setStatus('disconnected'); - showTombstone(); - } else { - setStatus('reconnecting'); - } - reconnectTimer = setTimeout(connect, reconnectDelay); - reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS); - }; - - // Let onclose own reconnection so we don't schedule it twice. - ws.onerror = () => { try { ws.close(); } catch (e) {} }; - } - - function sendEvent(event) { - event.timestamp = Date.now(); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(event)); - } else { - eventQueue.push(event); - } - } - - // Capture clicks on choice elements - document.addEventListener('click', (e) => { - const target = e.target.closest('[data-choice]'); - if (!target) return; - - sendEvent({ - type: 'click', - text: target.textContent.trim(), - choice: target.dataset.choice, - id: target.id || null - }); - - }); - - // Frame UI: selection tracking - window.selectedChoice = null; - - window.toggleSelect = function(el) { - const container = el.closest('.options') || el.closest('.cards'); - const multi = container && container.dataset.multiselect !== undefined; - if (container && !multi) { - container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected')); - } - if (multi) { - el.classList.toggle('selected'); - } else { - el.classList.add('selected'); - } - window.selectedChoice = el.dataset.choice; - }; - - // Expose API for explicit use - window.brainstorm = { - send: sendEvent, - choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }) - }; - - connect(); -})(); diff --git a/skills/thirdparty/brainstorming/scripts/server.cjs b/skills/thirdparty/brainstorming/scripts/server.cjs deleted file mode 100644 index a828b35a..00000000 --- a/skills/thirdparty/brainstorming/scripts/server.cjs +++ /dev/null @@ -1,723 +0,0 @@ -const crypto = require('crypto'); -const http = require('http'); -const fs = require('fs'); -const path = require('path'); - -// ========== WebSocket Protocol (RFC 6455) ========== - -const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A }; -const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; -const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024; - -function computeAcceptKey(clientKey) { - return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64'); -} - -function encodeFrame(opcode, payload) { - const fin = 0x80; - const len = payload.length; - let header; - - if (len < 126) { - header = Buffer.alloc(2); - header[0] = fin | opcode; - header[1] = len; - } else if (len < 65536) { - header = Buffer.alloc(4); - header[0] = fin | opcode; - header[1] = 126; - header.writeUInt16BE(len, 2); - } else { - header = Buffer.alloc(10); - header[0] = fin | opcode; - header[1] = 127; - header.writeBigUInt64BE(BigInt(len), 2); - } - - return Buffer.concat([header, payload]); -} - -function decodeFrame(buffer) { - if (buffer.length < 2) return null; - - const secondByte = buffer[1]; - const opcode = buffer[0] & 0x0F; - const masked = (secondByte & 0x80) !== 0; - let payloadLen = secondByte & 0x7F; - let offset = 2; - - if (!masked) throw new Error('Client frames must be masked'); - - if (payloadLen === 126) { - if (buffer.length < 4) return null; - payloadLen = buffer.readUInt16BE(2); - offset = 4; - } else if (payloadLen === 127) { - if (buffer.length < 10) return null; - const extendedLen = buffer.readBigUInt64BE(2); - if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) { - throw new Error('WebSocket frame payload exceeds maximum allowed size'); - } - payloadLen = Number(extendedLen); - offset = 10; - } - - if (payloadLen > MAX_FRAME_PAYLOAD_BYTES) { - throw new Error('WebSocket frame payload exceeds maximum allowed size'); - } - - const maskOffset = offset; - const dataOffset = offset + 4; - const totalLen = dataOffset + payloadLen; - if (buffer.length < totalLen) return null; - - const mask = buffer.slice(maskOffset, dataOffset); - const data = Buffer.alloc(payloadLen); - for (let i = 0; i < payloadLen; i++) { - data[i] = buffer[dataOffset + i] ^ mask[i % 4]; - } - - return { opcode, payload: data, bytesConsumed: totalLen }; -} - -// ========== Configuration ========== - -const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null; -const randomPort = () => 49152 + Math.floor(Math.random() * 16383); -// Prefer an explicit port, else the port this session last bound (so a restart -// reuses it and an already-open browser tab reconnects), else a random high port. -function preferredPort() { - if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT); - if (PORT_FILE) { - try { - const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim()); - if (Number.isInteger(p) && p > 1023 && p < 65536) return p; - } catch (e) { /* no prior port recorded */ } - } - return randomPort(); -} -let PORT = preferredPort(); -const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1'; -const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST); -const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm'; -const CONTENT_DIR = path.join(SESSION_DIR, 'content'); -const STATE_DIR = path.join(SESSION_DIR, 'state'); -const SUPERPOWERS_VERSION = readSuperpowersVersion(); -const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png'; -const TELEMETRY_DISABLE_ENV_VARS = [ - 'SUPERPOWERS_DISABLE_TELEMETRY', - 'DISABLE_TELEMETRY', - 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' -]; -const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name])); -let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null; - -// Per-session secret key. The companion is reachable by any local browser tab -// and, when bound to a non-loopback host, by any host that can route to it. -// The key authenticates the real client uniformly across loopback, tunnel, and -// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist -// cannot. It rides the served URL as ?key= and is mirrored into a cookie on -// first load so same-origin subresources and the WebSocket carry it for free. -// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the -// same key and an already-open tab's cookie still validates. -const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null; -function generateToken() { - return crypto.randomBytes(32).toString('hex'); -} - -function chmodOwnerOnly(file) { - try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ } -} - -function initialToken() { - if (process.env.BRAINSTORM_TOKEN) { - return { value: process.env.BRAINSTORM_TOKEN, source: 'env' }; - } - if (TOKEN_FILE) { - try { - const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim(); - if (/^[0-9a-f]{32,}$/i.test(t)) { - chmodOwnerOnly(TOKEN_FILE); - return { value: t, source: 'file' }; - } - } catch (e) { /* no prior token recorded */ } - } - return { value: generateToken(), source: 'generated' }; -} - -const tokenInfo = initialToken(); -let TOKEN = tokenInfo.value; -let tokenSource = tokenInfo.source; -let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen - -const MIME_TYPES = { - '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', - '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml' -}; - -// ========== Templates and Constants ========== - -function waitingPage() { - return renderBranding(` - -Brainstorm Companion - - -

Brainstorm Companion

-

Waiting for the agent to push a screen...

`); -} - -const FORBIDDEN_PAGE = ` - -Session key required - - -

Session key required

-

This page needs the full URL your coding agent gave you, including the -?key=… part. Copy the complete URL and open it again.

`; - -function bootstrapPage(key) { - const jsonKey = JSON.stringify(String(key)); - return ` - -Opening Brainstorm Companion - - - -`; -} - -const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8'); -const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8'); -const helperInjection = ''; - -// ========== Helper Functions ========== - -function readSuperpowersVersion() { - const root = path.join(__dirname, '../../..'); - const manifests = [ - path.join(root, 'package.json'), - path.join(root, '.codex-plugin/plugin.json') - ]; - - for (const manifest of manifests) { - try { - const data = JSON.parse(fs.readFileSync(manifest, 'utf-8')); - if (data.version) return String(data.version); - } catch (e) { - // Packaged Codex plugins omit package.json; try the next manifest. - } - } - - return 'unknown'; -} - -function isTruthyEnv(value) { - if (!value) return false; - const normalized = String(value).trim().toLowerCase(); - if (!normalized) return false; - return !['0', 'false', 'no', 'off'].includes(normalized); -} - -function escapeHtmlText(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function brandMarkup() { - const version = escapeHtmlText(SUPERPOWERS_VERSION); - const text = SUPERPOWERS_TELEMETRY_DISABLED - ? 'Prime Radiant Superpowers v' + version - : 'Superpowers v' + version; - const logo = SUPERPOWERS_TELEMETRY_DISABLED - ? '' - : ''; - - return ''; -} - -function renderBranding(html) { - return html.split('').join(brandMarkup()); -} - -function isFullDocument(html) { - const trimmed = html.trimStart().toLowerCase(); - return trimmed.startsWith('', content); -} - -function getNewestScreen() { - const files = fs.readdirSync(CONTENT_DIR) - .filter(f => !f.startsWith('.') && f.endsWith('.html')) - .map(f => { - const fp = path.join(CONTENT_DIR, f); - if (!isRegularFileInsideContentDir(fp)) return null; - return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; - }) - .filter(Boolean) - .sort((a, b) => b.mtime - a.mtime); - return files.length > 0 ? files[0].path : null; -} - -function urlHostForHttp(host) { - const h = String(host); - if (h.startsWith('[') && h.endsWith(']')) return h; - return h.includes(':') ? '[' + h + ']' : h; -} - -function companionUrl() { - return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN; -} - -function browserLauncherForPlatform(url, { - platform = process.platform, - osRelease = require('os').release(), - env = process.env -} = {}) { - const isWSL = platform === 'linux' && /microsoft/i.test(osRelease); - if (platform === 'darwin') return { bin: 'open', args: [url] }; - if (platform === 'win32' || isWSL) { - return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }; - } - if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] }; - return null; -} - -function isRegularFileInsideContentDir(filePath) { - let stat, realContentDir, realFilePath; - try { - stat = fs.lstatSync(filePath); - if (stat.isSymbolicLink()) return false; - if (!stat.isFile()) return false; - if (stat.nlink !== 1) return false; - realContentDir = fs.realpathSync(CONTENT_DIR); - realFilePath = fs.realpathSync(filePath); - } catch (e) { - return false; - } - return realFilePath.startsWith(realContentDir + path.sep); -} - -// ========== Authentication ========== - -function timingSafeEqualStr(a, b) { - const ab = Buffer.from(String(a)); - const bb = Buffer.from(String(b)); - if (ab.length !== bb.length) return false; - return crypto.timingSafeEqual(ab, bb); -} - -function parseCookies(header) { - const out = {}; - if (!header) return out; - for (const part of header.split(';')) { - const eq = part.indexOf('='); - if (eq < 0) continue; - out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); - } - return out; -} - -// A request is authorized if it carries the session key as ?key= or as the -// session cookie. Both are compared in constant time. -function isAuthorized(req) { - const q = req.url.indexOf('?'); - if (q >= 0) { - const params = new URLSearchParams(req.url.slice(q + 1)); - if (params.has('key')) { - const key = params.get('key'); - return Boolean(key && timingSafeEqualStr(key, TOKEN)); - } - } - const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME]; - if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true; - return false; -} - -function pathnameOf(url) { - const q = url.indexOf('?'); - return q >= 0 ? url.slice(0, q) : url; -} - -function queryKey(url) { - const q = url.indexOf('?'); - if (q < 0) return null; - return new URLSearchParams(url.slice(q + 1)).get('key'); -} - -function securityHeaders(headers = {}) { - return { - 'Referrer-Policy': 'no-referrer', - 'Cache-Control': 'no-store', - 'X-Frame-Options': 'DENY', - 'Content-Security-Policy': "frame-ancestors 'none'", - 'Cross-Origin-Resource-Policy': 'same-origin', - ...headers - }; -} - -function isAllowedWebSocketOrigin(req) { - const origin = req.headers.origin; - if (!origin) return true; - const host = req.headers.host; - if (!host) return false; - return origin === 'http://' + host; -} - -// ========== HTTP Request Handler ========== - -function handleRequest(req, res) { - if (!isAuthorized(req)) { - res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); - res.end(FORBIDDEN_PAGE); - return; - } - touchActivity(); // only authorized requests count as activity - - // Mirror the key into a cookie so same-origin subresources (/files/*) can - // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the - // WebSocket Origin check below is what blocks cross-origin localhost injection. - res.setHeader('Set-Cookie', - COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/'); - - const pathname = pathnameOf(req.url); - const keyFromQuery = queryKey(req.url); - if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) { - res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); - res.end(bootstrapPage(keyFromQuery)); - } else if (req.method === 'GET' && pathname === '/') { - const screenFile = getNewestScreen(); - let html = screenFile - ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8')) - : waitingPage(); - - if (html.includes('')) { - html = html.replace('', helperInjection + '\n'); - } else { - html += helperInjection; - } - - res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); - res.end(html); - } else if (req.method === 'GET' && pathname.startsWith('/files/')) { - const fileName = path.basename(pathname.slice(7)); - const filePath = path.join(CONTENT_DIR, fileName); - // Reject empty/dotfile names and anything that isn't a regular file — - // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR). - if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) { - res.writeHead(404, securityHeaders()); - res.end('Not found'); - return; - } - const ext = path.extname(filePath).toLowerCase(); - const contentType = MIME_TYPES[ext] || 'application/octet-stream'; - res.writeHead(200, securityHeaders({ 'Content-Type': contentType })); - res.end(fs.readFileSync(filePath)); - } else { - res.writeHead(404, securityHeaders()); - res.end('Not found'); - } -} - -// ========== WebSocket Connection Handling ========== - -const clients = new Set(); - -function handleUpgrade(req, socket) { - if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; } - - const key = req.headers['sec-websocket-key']; - if (!key) { socket.destroy(); return; } - - const accept = computeAcceptKey(key); - socket.write( - 'HTTP/1.1 101 Switching Protocols\r\n' + - 'Upgrade: websocket\r\n' + - 'Connection: Upgrade\r\n' + - 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n' - ); - - let buffer = Buffer.alloc(0); - clients.add(socket); - - socket.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - while (buffer.length > 0) { - let result; - try { - result = decodeFrame(buffer); - } catch (e) { - socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); - clients.delete(socket); - return; - } - if (!result) break; - buffer = buffer.slice(result.bytesConsumed); - - switch (result.opcode) { - case OPCODES.TEXT: - handleMessage(result.payload.toString()); - break; - case OPCODES.CLOSE: - socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); - clients.delete(socket); - return; - case OPCODES.PING: - socket.write(encodeFrame(OPCODES.PONG, result.payload)); - break; - case OPCODES.PONG: - break; - default: { - const closeBuf = Buffer.alloc(2); - closeBuf.writeUInt16BE(1003); - socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); - clients.delete(socket); - return; - } - } - } - }); - - socket.on('close', () => clients.delete(socket)); - socket.on('error', () => clients.delete(socket)); -} - -function handleMessage(text) { - let event; - try { - event = JSON.parse(text); - } catch (e) { - console.error('Failed to parse WebSocket message:', e.message); - return; - } - touchActivity(); - console.log(JSON.stringify({ source: 'user-event', ...event })); - if (event && event.choice) { - const eventsFile = path.join(STATE_DIR, 'events'); - fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n'); - } -} - -function broadcast(msg) { - const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg))); - for (const socket of clients) { - try { socket.write(frame); } catch (e) { clients.delete(socket); } - } -} - -// Best-effort: open the user's browser the first time a screen is actually ready -// to show. Skips when disabled, on a non-loopback (remote) bind, or when a -// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD. -let browserOpened = false; -function maybeOpenBrowser() { - if (browserOpened) return; - browserOpened = true; - if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion - if (HOST !== '127.0.0.1' && HOST !== 'localhost') return; - if (clients.size > 0) return; // the user already opened it - const url = companionUrl(); // must carry the key or the gate 403s it - const cp = require('child_process'); - // Operator-provided launcher: run as given (this env var is trusted operator input). - if (process.env.BRAINSTORM_OPEN_CMD) { - try { cp.exec(process.env.BRAINSTORM_OPEN_CMD + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ } - return; - } - // Platform launchers: pass the URL as an argv element via execFile (no shell), - // so a url-host containing shell metacharacters can't inject a command. - const launcher = browserLauncherForPlatform(url); - if (!launcher) return; // headless: nothing to open - try { cp.execFile(launcher.bin, launcher.args, () => {}); } catch (e) { /* best effort */ } -} - -// ========== Activity Tracking ========== - -// Idle timeout: shut down after this long with no activity. Default 4 hours; -// override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes). -const IDLE_TIMEOUT_MS = (() => { - const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS); - return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000; -})(); -// How often the watchdog checks for owner-death / idleness. Configurable mainly -// so tests can run fast; production default is 60s. -const LIFECYCLE_CHECK_MS = (() => { - const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS); - return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000; -})(); -let lastActivity = Date.now(); - -function touchActivity() { - lastActivity = Date.now(); -} - -// ========== File Watching ========== - -const debounceTimers = new Map(); - -// ========== Server Startup ========== - -function startServer() { - if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true }); - if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); - - // Track known files to distinguish new screens from updates. - // macOS fs.watch reports 'rename' for both new files and overwrites, - // so we can't rely on eventType alone. - const knownFiles = new Set( - fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html')) - ); - - const server = http.createServer(handleRequest); - server.on('upgrade', handleUpgrade); - - const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => { - if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return; - - if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename)); - debounceTimers.set(filename, setTimeout(() => { - debounceTimers.delete(filename); - const filePath = path.join(CONTENT_DIR, filename); - - if (!fs.existsSync(filePath)) return; // file was deleted - touchActivity(); - - if (!knownFiles.has(filename)) { - knownFiles.add(filename); - const eventsFile = path.join(STATE_DIR, 'events'); - if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile); - console.log(JSON.stringify({ type: 'screen-added', file: filePath })); - maybeOpenBrowser(); - } else { - console.log(JSON.stringify({ type: 'screen-updated', file: filePath })); - } - - broadcast({ type: 'reload' }); - }, 100)); - }); - watcher.on('error', (err) => console.error('fs.watch error:', err.message)); - - function shutdown(reason) { - console.log(JSON.stringify({ type: 'server-stopped', reason })); - const infoFile = path.join(STATE_DIR, 'server-info'); - if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); - fs.writeFileSync( - path.join(STATE_DIR, 'server-stopped'), - JSON.stringify({ reason, timestamp: Date.now() }) + '\n' - ); - watcher.close(); - clearInterval(lifecycleCheck); - // Close any upgraded WebSocket sockets so server.close() can complete and - // the process actually exits instead of lingering on an open connection. - for (const socket of clients) { - try { socket.destroy(); } catch (e) { /* already gone */ } - } - server.close(() => process.exit(0)); - } - - function ownerAlive() { - if (!ownerPid) return true; - try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; } - } - - // Periodically exit if the owner process died or we've been idle too long. - const lifecycleCheck = setInterval(() => { - if (!ownerAlive()) shutdown('owner process exited'); - else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout'); - }, LIFECYCLE_CHECK_MS); - lifecycleCheck.unref(); - - // Validate owner PID at startup. If it's already dead, the PID resolution - // was wrong (common on WSL, Tailscale SSH, and cross-user scenarios). - // Disable monitoring and rely on the idle timeout instead. - if (ownerPid) { - try { process.kill(ownerPid, 0); } - catch (e) { - if (e.code !== 'EPERM') { - console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' })); - ownerPid = null; - } - } - } - - // If the preferred port is already taken (e.g. a previous server is still - // alive), fall back to a random port once instead of failing. - let triedFallback = false; - - function onListen() { - // Cookie name keys on the ACTUAL bound port (may differ from the preferred - // one after an EADDRINUSE fallback) so it can't collide with another server's - // cookie in the shared localhost jar. - COOKIE_NAME = 'brainstorm-key-' + PORT; - // Record the bound port AND token so the next restart of this session reuses - // them — but ONLY when we got our preferred port. On a fallback we bound a - // *different* port because someone else holds the preferred one; persisting - // would overwrite the shared files and strand that other session's open tab. - if (PORT_FILE && !triedFallback) { - try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ } - if (TOKEN_FILE) { - try { - fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 }); - chmodOwnerOnly(TOKEN_FILE); - } catch (e) { /* best effort */ } - } - } - const info = JSON.stringify({ - type: 'server-started', port: Number(PORT), host: HOST, - url_host: URL_HOST, url: companionUrl(), - screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS - }); - console.log(info); - // server-info embeds the key — keep it owner-only. - fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 }); - } - - server.on('error', (err) => { - if (err.code === 'EADDRINUSE' && !triedFallback) { - if (tokenSource === 'env') { - console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token'); - process.exit(1); - } - triedFallback = true; - PORT = randomPort(); - if (tokenSource === 'file') { - TOKEN = generateToken(); - tokenSource = 'generated-fallback'; - } - server.listen(PORT, HOST, onListen); - } else { - console.error('Server failed to bind:', err.message); - process.exit(1); - } - }); - server.listen(PORT, HOST, onListen); -} - -if (require.main === module) { - startServer(); -} - -module.exports = { - computeAcceptKey, - encodeFrame, - decodeFrame, - browserLauncherForPlatform, - OPCODES, - MAX_FRAME_PAYLOAD_BYTES -}; diff --git a/skills/thirdparty/brainstorming/scripts/start-server.sh b/skills/thirdparty/brainstorming/scripts/start-server.sh deleted file mode 100755 index 016a8e48..00000000 --- a/skills/thirdparty/brainstorming/scripts/start-server.sh +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env bash -# Start the brainstorm server and output connection info -# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--foreground] [--background] -# -# Starts server on a random high port, outputs JSON with URL. -# Each session gets its own directory to avoid conflicts. -# -# Options: -# --project-dir Store session files under /.superpowers/brainstorm/ -# instead of /tmp. Files persist after server stops. -# --host Host/interface to bind (default: 127.0.0.1). -# Use 0.0.0.0 in remote/containerized environments. -# --url-host Hostname shown in returned URL JSON. -# --idle-timeout-minutes Shut down after n minutes idle (default 240 = 4h). -# --open Auto-open the browser on the first screen (use only -# after the user approves the visual companion). -# --foreground Run server in the current terminal (no backgrounding). -# --background Force background mode (overrides Codex auto-foreground). - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# Parse arguments -PROJECT_DIR="" -FOREGROUND="false" -FORCE_BACKGROUND="false" -BIND_HOST="127.0.0.1" -URL_HOST="" -IDLE_TIMEOUT_MINUTES="" -while [[ $# -gt 0 ]]; do - case "$1" in - --project-dir) - PROJECT_DIR="$2" - shift 2 - ;; - --host) - BIND_HOST="$2" - shift 2 - ;; - --url-host) - URL_HOST="$2" - shift 2 - ;; - --idle-timeout-minutes) - IDLE_TIMEOUT_MINUTES="$2" - shift 2 - ;; - --open) - export BRAINSTORM_OPEN=1 - shift - ;; - --foreground|--no-daemon) - FOREGROUND="true" - shift - ;; - --background|--daemon) - FORCE_BACKGROUND="true" - shift - ;; - *) - echo "{\"error\": \"Unknown argument: $1\"}" - exit 1 - ;; - esac -done - -if [[ -z "$URL_HOST" ]]; then - if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then - URL_HOST="localhost" - else - URL_HOST="$BIND_HOST" - fi -fi - -if [[ -n "$IDLE_TIMEOUT_MINUTES" ]]; then - if ! [[ "$IDLE_TIMEOUT_MINUTES" =~ ^[0-9]+$ ]] || [[ "$IDLE_TIMEOUT_MINUTES" -lt 1 ]]; then - echo "{\"error\": \"--idle-timeout-minutes must be a positive integer\"}" - exit 1 - fi - export BRAINSTORM_IDLE_TIMEOUT_MS=$(( IDLE_TIMEOUT_MINUTES * 60 * 1000 )) -fi - -is_windows_like_shell() { - case "${OSTYPE:-}" in - msys*|cygwin*|mingw*) return 0 ;; - esac - if [[ -n "${MSYSTEM:-}" ]]; then - return 0 - fi - local uname_s - uname_s="$(uname -s 2>/dev/null || true)" - case "$uname_s" in - MSYS*|MINGW*|CYGWIN*) return 0 ;; - esac - return 1 -} - -# Some environments reap detached/background processes. Auto-foreground when detected. -if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then - FOREGROUND="true" -fi - -# Windows/Git Bash reaps nohup background processes. Auto-foreground when detected. -if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then - if is_windows_like_shell; then - FOREGROUND="true" - fi -fi - -# Session files (server.log, server-info, .last-token) embed the session key — -# keep everything this script and the server create owner-only. -umask 077 - -# Generate unique session directory -SESSION_ID="$$-$(date +%s)" - -if [[ -n "$PROJECT_DIR" ]]; then - SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}" - # Persist the bound port and key per project so a restart reuses them and an - # already-open browser tab reconnects to the same URL with a valid cookie. - export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port" - export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token" -else - SESSION_DIR="/tmp/brainstorm-${SESSION_ID}" -fi - -STATE_DIR="${SESSION_DIR}/state" -PID_FILE="${STATE_DIR}/server.pid" -LOG_FILE="${STATE_DIR}/server.log" -SERVER_ID_FILE="${STATE_DIR}/server-instance-id" - -# Create fresh session directory with content and state peers -mkdir -p "${SESSION_DIR}/content" "$STATE_DIR" - -SERVER_ID="" -if [[ -r /dev/urandom ]]; then - SERVER_ID="$(od -An -N24 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true)" -fi -if ! [[ "$SERVER_ID" =~ ^[A-Za-z0-9_-]{32,64}$ ]]; then - SERVER_ID="$(printf '%08x%08x%08x%08x' "$$" "$(date +%s)" "${RANDOM:-0}" "${RANDOM:-0}")" -fi -printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE" -chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true - -# Kill any existing server -if [[ -f "$PID_FILE" ]]; then - old_pid=$(cat "$PID_FILE") - kill "$old_pid" 2>/dev/null - rm -f "$PID_FILE" -fi - -cd "$SCRIPT_DIR" || exit 1 - -# Resolve the harness PID (grandparent of this script). -# $PPID is the ephemeral shell the harness spawned to run us — it dies -# when this script exits. The harness itself is $PPID's parent. -OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')" -if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then - OWNER_PID="$PPID" -fi - -# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace. -# Passing a PID node cannot verify causes server to log owner-pid-invalid -# and self-terminate at the 60-second lifecycle check. Clear it so the -# watchdog is disabled and the idle timeout becomes the only shutdown trigger. -if is_windows_like_shell; then - OWNER_PID="" -fi - -# Foreground mode for environments that reap detached/background processes. -if [[ "$FOREGROUND" == "true" ]]; then - env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" & - SERVER_PID=$! - echo "$SERVER_PID" > "$PID_FILE" - wait "$SERVER_PID" - exit $? -fi - -# Start server, capturing output to log file -# Use nohup to survive shell exit; disown to remove from job table -nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 & -SERVER_PID=$! -disown "$SERVER_PID" 2>/dev/null -echo "$SERVER_PID" > "$PID_FILE" - -# Wait for server-started message (check log file) -for _ in {1..50}; do - if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then - # Verify server is still alive after a short window (catches process reapers) - alive="true" - for _ in {1..20}; do - if ! kill -0 "$SERVER_PID" 2>/dev/null; then - alive="false" - break - fi - sleep 0.1 - done - if [[ "$alive" != "true" ]]; then - echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}" - exit 1 - fi - grep "server-started" "$LOG_FILE" | head -1 - exit 0 - fi - sleep 0.1 -done - -# Timeout - server didn't start -echo '{"error": "Server failed to start within 5 seconds"}' -exit 1 diff --git a/skills/thirdparty/brainstorming/scripts/stop-server.sh b/skills/thirdparty/brainstorming/scripts/stop-server.sh deleted file mode 100755 index 7cacfe94..00000000 --- a/skills/thirdparty/brainstorming/scripts/stop-server.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -# Stop the brainstorm server and clean up -# Usage: stop-server.sh -# -# Kills the server process. Only deletes session directory if it's -# under /tmp (ephemeral). Persistent directories (.superpowers/) are -# kept so mockups can be reviewed later. - -SESSION_DIR="$1" - -if [[ -z "$SESSION_DIR" ]]; then - echo '{"error": "Usage: stop-server.sh "}' - exit 1 -fi - -STATE_DIR="${SESSION_DIR}/state" -PID_FILE="${STATE_DIR}/server.pid" -SERVER_ID_FILE="${STATE_DIR}/server-instance-id" - -mark_stopped() { - local reason="$1" - rm -f "${STATE_DIR}/server-info" - printf '{"reason":"%s","timestamp":%s}\n' "$reason" "$(date +%s)" > "${STATE_DIR}/server-stopped" -} - -read_expected_server_id() { - [[ -f "$SERVER_ID_FILE" ]] || return 1 - local id - id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)" - [[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1 - printf '%s\n' "$id" -} - -command_line_for_pid() { - local pid="$1" - if [[ -r "/proc/$pid/cmdline" ]]; then - tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true - return 0 - fi - ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true -} - -command_has_server_id() { - local pid="$1" - local expected="$2" - local expected_arg="--brainstorm-server-id=$expected" - if [[ -r "/proc/$pid/cmdline" ]]; then - local arg - while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do - [[ "$arg" == "$expected_arg" ]] && return 0 - done < "/proc/$pid/cmdline" - return 1 - fi - local command_line - command_line="$(command_line_for_pid "$pid")" - [[ -n "$command_line" ]] || return 1 - case " $command_line " in - *" $expected_arg "*) return 0 ;; - *) return 1 ;; - esac -} - -# Confirm a PID has this session's per-start instance id, not just a familiar -# process name. Ambiguous or legacy metadata fails closed as stale_pid. -is_brainstorm_server() { - kill -0 "$1" 2>/dev/null || return 1 - local expected_id - expected_id="$(read_expected_server_id)" || return 1 - command_has_server_id "$1" "$expected_id" || return 1 - return 0 -} - -if [[ -f "$PID_FILE" ]]; then - pid=$(cat "$PID_FILE") - - # Refuse to signal a PID we can't prove is our server. A stale pid file may - # point at an unrelated process after a reboot/PID wraparound. - if ! is_brainstorm_server "$pid"; then - rm -f "$PID_FILE" "$SERVER_ID_FILE" - mark_stopped "stale_pid" - echo '{"status": "stale_pid"}' - exit 0 - fi - - # Try to stop gracefully, fallback to force if still alive - kill "$pid" 2>/dev/null || true - - # Wait for graceful shutdown (up to ~2s) - for _ in {1..20}; do - if ! kill -0 "$pid" 2>/dev/null; then - break - fi - sleep 0.1 - done - - # If still running, escalate to SIGKILL - if kill -0 "$pid" 2>/dev/null; then - kill -9 "$pid" 2>/dev/null || true - - # Give SIGKILL a moment to take effect - sleep 0.1 - fi - - if kill -0 "$pid" 2>/dev/null; then - echo '{"status": "failed", "error": "process still running"}' - exit 1 - fi - - rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log" - mark_stopped "stop-server.sh" - - # Only delete ephemeral /tmp directories - if [[ "$SESSION_DIR" == /tmp/* ]]; then - rm -rf "$SESSION_DIR" - fi - - echo '{"status": "stopped"}' -else - echo '{"status": "not_running"}' -fi diff --git a/skills/thirdparty/brainstorming/spec-document-reviewer-prompt.md b/skills/thirdparty/brainstorming/spec-document-reviewer-prompt.md deleted file mode 100644 index 60993129..00000000 --- a/skills/thirdparty/brainstorming/spec-document-reviewer-prompt.md +++ /dev/null @@ -1,49 +0,0 @@ -# Spec Document Reviewer Prompt Template - -Use this template when dispatching a spec document reviewer subagent. - -**Purpose:** Verify the spec is complete, consistent, and ready for implementation planning. - -**Dispatch after:** Spec document is written to docs/superpowers/specs/ - -``` -Subagent (general-purpose): - description: "Review spec document" - prompt: | - You are a spec document reviewer. Verify this spec is complete and ready for planning. - - **Spec to review:** [SPEC_FILE_PATH] - - ## What to Check - - | Category | What to Look For | - |----------|------------------| - | Completeness | TODOs, placeholders, "TBD", incomplete sections | - | Consistency | Internal contradictions, conflicting requirements | - | Clarity | Requirements ambiguous enough to cause someone to build the wrong thing | - | Scope | Focused enough for a single plan — not covering multiple independent subsystems | - | YAGNI | Unrequested features, over-engineering | - - ## Calibration - - **Only flag issues that would cause real problems during implementation planning.** - A missing section, a contradiction, or a requirement so ambiguous it could be - interpreted two different ways — those are issues. Minor wording improvements, - stylistic preferences, and "sections less detailed than others" are not. - - Approve unless there are serious gaps that would lead to a flawed plan. - - ## Output Format - - ## Spec Review - - **Status:** Approved | Issues Found - - **Issues (if any):** - - [Section X]: [specific issue] - [why it matters for planning] - - **Recommendations (advisory, do not block approval):** - - [suggestions for improvement] -``` - -**Reviewer returns:** Status, Issues (if any), Recommendations diff --git a/skills/thirdparty/brainstorming/visual-companion.md b/skills/thirdparty/brainstorming/visual-companion.md deleted file mode 100644 index 906c9ac8..00000000 --- a/skills/thirdparty/brainstorming/visual-companion.md +++ /dev/null @@ -1,298 +0,0 @@ -# Visual Companion Guide - -Browser-based visual brainstorming companion for showing mockups, diagrams, and options. - -## When to Use - -Decide per-question, not per-session. The test: **would the user understand this better by seeing it than reading it?** - -**Use the browser** when the content itself is visual: - -- **UI mockups** — wireframes, layouts, navigation structures, component designs -- **Architecture diagrams** — system components, data flow, relationship maps -- **Side-by-side visual comparisons** — comparing two layouts, two color schemes, two design directions -- **Design polish** — when the question is about look and feel, spacing, visual hierarchy -- **Spatial relationships** — state machines, flowcharts, entity relationships rendered as diagrams - -**Use the terminal** when the content is text or tabular: - -- **Requirements and scope questions** — "what does X mean?", "which features are in scope?" -- **Conceptual A/B/C choices** — picking between approaches described in words -- **Tradeoff lists** — pros/cons, comparison tables -- **Technical decisions** — API design, data modeling, architectural approach selection -- **Clarifying questions** — anything where the answer is words, not a visual preference - -A question *about* a UI topic is not automatically a visual question. "What kind of wizard do you want?" is conceptual — use the terminal. "Which of these wizard layouts feels right?" is visual — use the browser. - -## How It Works - -The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user sees it in their browser and can click to select options. Selections are recorded to `state_dir/events` that you read on your next turn. - -**Content fragments vs full documents:** If your HTML file starts with `/.superpowers/brainstorm/` for the session directory. - -**Note:** Pass the project root as `--project-dir` so mockups persist in `.superpowers/brainstorm/` and survive server restarts. Without it, files go to `/tmp` and get cleaned up. Remind the user to add `.superpowers/` to `.gitignore` if it's not already there. - -**Launching the server by platform:** - -**Claude Code:** -```bash -# Default mode works — the script backgrounds the server itself. -scripts/start-server.sh --project-dir /path/to/project --open -``` - -On Windows, the script auto-detects and switches to foreground mode (which blocks the tool call). Use `run_in_background: true` on the Bash tool call so the server survives across conversation turns, then read `$STATE_DIR/server-info` on the next turn to get the URL and port. - -**Codex:** -```bash -# Codex reaps background processes. The script auto-detects CODEX_CI and -# switches to foreground mode. Run it normally — no extra flags needed. -scripts/start-server.sh --project-dir /path/to/project --open -``` - -**Gemini CLI:** -```bash -# Use --foreground and set is_background: true on your shell tool call -# so the process survives across turns -scripts/start-server.sh --project-dir /path/to/project --open --foreground -``` - -**Copilot CLI:** -```bash -# Use --foreground and start the server via the bash tool with mode: "async" -# so the process survives across turns. Capture the returned shellId for -# read_bash / stop_bash if you need to interact with it later. -scripts/start-server.sh --project-dir /path/to/project --open --foreground -``` - -**Other environments:** The server must keep running in the background across conversation turns. If your environment reaps detached processes, use `--foreground` and launch the command with your platform's background execution mechanism. - -If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host: - -```bash -scripts/start-server.sh \ - --project-dir /path/to/project \ - --host 0.0.0.0 \ - --url-host localhost -``` - -Use `--url-host` to control what hostname is printed in the returned URL JSON. - -## The Loop - -1. **Check server is alive**, then **write HTML** to a new file in `screen_dir`: - - **Required: confirm the server is alive before referring to the URL or pushing a screen.** Check that `$STATE_DIR/server-info` exists and `$STATE_DIR/server-stopped` does not. If it has shut down, restart it with `start-server.sh` using the **same `--project-dir`** — it reuses the same port, so the user's open tab reconnects on its own (it shows a "paused" overlay while the server is down) and you don't need to send a new URL. The server auto-exits after 4 hours idle (configurable with `--idle-timeout-minutes`). - - Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html` - - **Never reuse filenames** — each screen gets a fresh file - - Use your file-creation tool — **never use cat/heredoc** (dumps noise into terminal) - - Server automatically serves the newest file - -2. **Tell user what to expect and end your turn:** - - Remind them of the URL (every step, not just first) - - Give a brief text summary of what's on screen (e.g., "Showing 3 layout options for the homepage") - - Ask them to respond in the terminal: "Take a look and let me know what you think. Click to select an option if you'd like." - -3. **On your next turn** — after the user responds in the terminal: - - Read `$STATE_DIR/events` if it exists — this contains the user's browser interactions (clicks, selections) as JSON lines - - Merge with the user's terminal text to get the full picture - - The terminal message is the primary feedback; `state_dir/events` provides structured interaction data - -4. **Iterate or advance** — if feedback changes current screen, write a new file (e.g., `layout-v2.html`). Only move to the next question when the current step is validated. - -5. **Unload when returning to terminal** — when the next step doesn't need the browser (e.g., a clarifying question, a tradeoff discussion), push a waiting screen to clear the stale content: - - ```html - -
-

Continuing in terminal...

-
- ``` - - This prevents the user from staring at a resolved choice while the conversation has moved on. When the next visual question comes up, push a new content file as usual. - -6. Repeat until done. - -## Writing Content Fragments - -Write just the content that goes inside the page. The server wraps it in the frame template automatically (header, theme CSS, connection status, and all interactive infrastructure). - -**Minimal example:** - -```html -

Which layout works better?

-

Consider readability and visual hierarchy

- -
-
-
A
-
-

Single Column

-

Clean, focused reading experience

-
-
-
-
B
-
-

Two Column

-

Sidebar navigation with main content

-
-
-
-``` - -That's it. No ``, no CSS, no ` + + + + +
+
...
+
...
+
...
+
+ + +``` + +## Header + +Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph — straight into the candidates. + +## Candidate card + +The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms (from the `/codebase-design` skill) without ceremony. + +Each candidate is one `
`: + +- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline"). +- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`). +- **Files** — monospaced list, `font-mono text-sm`. +- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below. +- **Problem** — one sentence. What hurts. +- **Solution** — one sentence. What changes. +- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers". +- **ADR callout** (if applicable) — one line in an amber-tinted box. + +No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram. + +## Diagram patterns + +Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point. + +### Mermaid graph (the workhorse for dependencies / call flow) + +Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1." + +```html +
+
+    flowchart LR
+      A[OrderHandler] --> B[OrderValidator]
+      B --> C[OrderRepo]
+      C -.leak.-> D[PricingClient]
+      classDef leak stroke:#dc2626,stroke-width:2px;
+      class C,D leak
+  
+
+``` + +### Hand-built boxes-and-arrows (when Mermaid's layout fights you) + +Modules as `
`s with borders and labels. Arrows as inline SVG `` or `` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight. + +### Cross-section (good for layered shallowness) + +Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility. + +### Mass diagram (good for "interface as wide as implementation") + +Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep). + +### Call-graph collapse + +Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it. + +## Style guidance + +- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate). +- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings. +- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling. +- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI. +- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering. + +## Top recommendation section + +One larger card. Candidate name, one sentence on why, anchor link to its card. That's it. + +## Tone + +Plain English, concise — but the architectural nouns and verbs come straight from the `/codebase-design` skill. Concision is not an excuse to drift. + +**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality. + +**Never substitute:** component, service, unit (for module) · API, signature (for interface) · boundary (for seam) · layer, wrapper (for module, when you mean module). + +**Phrasings that fit the style:** + +- "Order intake module is shallow — interface nearly matches the implementation." +- "Pricing leaks across the seam." +- "Deepen: one interface, one place to test." +- "Two adapters justify the seam: HTTP in prod, in-memory in tests." + +**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place. + +No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in the `/codebase-design` glossary, reach for one that is before inventing a new one. diff --git a/skills/thirdparty/improve-codebase-architecture/SKILL.md b/skills/thirdparty/improve-codebase-architecture/SKILL.md new file mode 100644 index 00000000..529761a3 --- /dev/null +++ b/skills/thirdparty/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick. +disable-model-invocation: true +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +This command is _informed_ by the project's domain model and built on a shared design vocabulary: + +- Run the `/codebase-design` skill for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use these terms exactly in every suggestion — don't drift into "component," "service," "API," or "boundary." +- The domain language in `CONTEXT.md` gives names to good seams; ADRs in `docs/adr/` record decisions this command should not re-litigate. + +## Process + +### 1. Explore + +**Scope before you scan — YAGNI.** Deepening a module pays off by making future changes to it easier, so put extra weight on the parts of the codebase that have recently changed. Decide *where* to look before you look: + +- If the user named a direction — a module, a subsystem, a pain point — take it, and skip the inference below. +- Otherwise, walk back a good stretch of the commit history (`git log --oneline`) to find the codebase's hot spots — the files and areas that keep coming up — and let those paths pull your attention first. If the changes are scattered with no clear hot spot, widen the net. + +Read the project's domain glossary (`CONTEXT.md`) and any ADRs in the area you're touching first. + +Then spawn a sub-agent to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates as an HTML report + +Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `/architecture-review-.html` so each run gets a fresh file. Open it for the user — `xdg-open ` on Linux, `open ` on macOS, `start ` on Windows — and tell them the absolute path. + +The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual. + +For each candidate, render a card with: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and how tests would improve +- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening +- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge + +End the report with a **Top recommendation** section: which candidate you'd tackle first and why. + +**Use CONTEXT.md vocabulary for the domain, and the `/codebase-design` vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly in the card (e.g. a warning callout: _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +See [HTML-REPORT.md](HTML-REPORT.md) for the full HTML scaffold, diagram patterns, and styling guidance. + +Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, run the `/grilling` skill to walk the decision tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize — run the `/domain-modeling` skill to keep the domain model current as you go: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md`. Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. +- **Want to explore alternative interfaces for the deepened module?** Run the `/codebase-design` skill and use its design-it-twice parallel sub-agent pattern. diff --git a/skills/thirdparty/improve-codebase-architecture/agents/openai.yaml b/skills/thirdparty/improve-codebase-architecture/agents/openai.yaml new file mode 100644 index 00000000..706fdca0 --- /dev/null +++ b/skills/thirdparty/improve-codebase-architecture/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Improve Codebase Architecture" + short_description: "Find and grill architecture improvements" +policy: + allow_implicit_invocation: false diff --git a/skills/thirdparty/migrate-to-shoehorn/SKILL.md b/skills/thirdparty/migrate-to-shoehorn/SKILL.md new file mode 100644 index 00000000..ae4f965e --- /dev/null +++ b/skills/thirdparty/migrate-to-shoehorn/SKILL.md @@ -0,0 +1,118 @@ +--- +name: migrate-to-shoehorn +description: Migrate test files from `as` type assertions to @total-typescript/shoehorn. Use when user mentions shoehorn, wants to replace `as` in tests, or needs partial test data. +--- + +# Migrate to Shoehorn + +## Why shoehorn? + +`shoehorn` lets you pass partial data in tests while keeping TypeScript happy. It replaces `as` assertions with type-safe alternatives. + +**Test code only.** Never use shoehorn in production code. + +Problems with `as` in tests: + +- Trained not to use it +- Must manually specify target type +- Double-as (`as unknown as Type`) for intentionally wrong data + +## Install + +```bash +npm i @total-typescript/shoehorn +``` + +## Migration patterns + +### Large objects with few needed properties + +Before: + +```ts +type Request = { + body: { id: string }; + headers: Record; + cookies: Record; + // ...20 more properties +}; + +it("gets user by id", () => { + // Only care about body.id but must fake entire Request + getUser({ + body: { id: "123" }, + headers: {}, + cookies: {}, + // ...fake all 20 properties + }); +}); +``` + +After: + +```ts +import { fromPartial } from "@total-typescript/shoehorn"; + +it("gets user by id", () => { + getUser( + fromPartial({ + body: { id: "123" }, + }), + ); +}); +``` + +### `as Type` → `fromPartial()` + +Before: + +```ts +getUser({ body: { id: "123" } } as Request); +``` + +After: + +```ts +import { fromPartial } from "@total-typescript/shoehorn"; + +getUser(fromPartial({ body: { id: "123" } })); +``` + +### `as unknown as Type` → `fromAny()` + +Before: + +```ts +getUser({ body: { id: 123 } } as unknown as Request); // wrong type on purpose +``` + +After: + +```ts +import { fromAny } from "@total-typescript/shoehorn"; + +getUser(fromAny({ body: { id: 123 } })); +``` + +## When to use each + +| Function | Use case | +| --------------- | -------------------------------------------------- | +| `fromPartial()` | Pass partial data that still type-checks | +| `fromAny()` | Pass intentionally wrong data (keeps autocomplete) | +| `fromExact()` | Force full object (swap with fromPartial later) | + +## Workflow + +1. **Gather requirements** - ask user: + - What test files have `as` assertions causing problems? + - Are they dealing with large objects where only some properties matter? + - Do they need to pass intentionally wrong data for error testing? + +2. **Install and migrate**: + - [ ] Install: `npm i @total-typescript/shoehorn` + - [ ] Find test files with `as` assertions: `grep -r " as [A-Z]" --include="*.test.ts" --include="*.spec.ts"` + - [ ] Replace `as Type` with `fromPartial()` + - [ ] Replace `as unknown as Type` with `fromAny()` + - [ ] Add imports from `@total-typescript/shoehorn` + - [ ] Run type check to verify diff --git a/skills/thirdparty/migrate-to-shoehorn/agents/openai.yaml b/skills/thirdparty/migrate-to-shoehorn/agents/openai.yaml new file mode 100644 index 00000000..3bd79ee2 --- /dev/null +++ b/skills/thirdparty/migrate-to-shoehorn/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Migrate to Shoehorn" + short_description: "Replace test assertions with shoehorn" diff --git a/skills/thirdparty/prototype/LOGIC.md b/skills/thirdparty/prototype/LOGIC.md new file mode 100644 index 00000000..5f5a3fd5 --- /dev/null +++ b/skills/thirdparty/prototype/LOGIC.md @@ -0,0 +1,67 @@ +# Logic Prototype + +A single, self-contained HTML file — a **shareable demo** — that lets anyone drive a state model by clicking buttons. Use this when the question is about **business logic, state transitions, or data shape** — the kind of thing that looks reasonable on paper but only feels wrong once you push it through real cases. + +Because it's one file with nothing to install, you can hand it to a non-developer — a designer, a PM, a domain expert — and let them feel the model for themselves. So it speaks their language, not the code's. + +## When this is the right shape + +- "I'm not sure if this state machine handles the edge case where X then Y." +- "Does this data model actually let me represent the case where..." +- "I want to feel out what the API should look like before writing it." +- Anything where someone wants to **press buttons and watch state change**. + +If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md). + +## Process + +### 1. State the question + +Before writing code, write down what state model and what question you're prototyping. One paragraph, at the top of the demo (in a visible intro, not just a comment). A logic prototype that answers the wrong question is pure waste — make the question explicit so it can be checked later, whether the user is watching now or returning to it AFK. + +### 2. Isolate the logic in a portable module + +Put the actual logic — the bit that's answering the question — in a single `