From 34c31004d129a4c0fd6a3c1cf4ed726b45677e0e Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:17:37 +0200 Subject: [PATCH 01/16] feat(seed): always seed wizrd:* pipeline labels at L1/L2 --- seed-labels.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/seed-labels.sh b/seed-labels.sh index 1b82187..a1565a1 100755 --- a/seed-labels.sh +++ b/seed-labels.sh @@ -51,4 +51,18 @@ case "$LEVEL" in ;; esac +# Pipeline labels — agent-managed state machine, namespaced wizrd:*. +# Always seeded at L1/L2. Harmless on repos that don't run the pipeline. +# Skipped at L0 (L0 itself doesn't run code pipelines). +if [[ "$LEVEL" == "L1" || "$LEVEL" == "L2" ]]; then + echo "Seeding pipeline labels on $REPO..." + upsert "wizrd:triage" "0e8a16" "agent is triaging the issue" + upsert "wizrd:plan" "0366d6" "agent is drafting a plan" + upsert "wizrd:implement" "a2eeef" "agent is implementing" + upsert "wizrd:review" "d4c5f9" "review-loop active" + upsert "wizrd:verify" "fbca04" "verify stage running" + upsert "wizrd:awaiting-approval" "fbca04" "human-approval gate — waiting on operator" + upsert "wizrd:needs-judgment" "d93f0b" "agent failed — human required" +fi + echo "Done." From 248718d534b375439e98d3872be225f4661e0cb0 Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:21:21 +0200 Subject: [PATCH 02/16] feat(workflows): add deterministic wizrd-tagger reusable --- .github/workflows/wizrd-tagger.yml | 112 +++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .github/workflows/wizrd-tagger.yml diff --git a/.github/workflows/wizrd-tagger.yml b/.github/workflows/wizrd-tagger.yml new file mode 100644 index 0000000..93cb236 --- /dev/null +++ b/.github/workflows/wizrd-tagger.yml @@ -0,0 +1,112 @@ +name: Wizrd Tagger +# Deterministic — no LLM. Auto-applies the first-stage label to new issues +# that match the configured filter, then dispatches the first stage's +# workflow. Required because GitHub suppresses `labeled` events for +# labels added via GITHUB_TOKEN (infinite-loop guard). + +on: + workflow_call: + inputs: + first_stage_label: + description: 'Label to apply when an issue matches (e.g. wizrd:triage).' + required: true + type: string + first_stage_workflow: + description: 'Basename of the first stage workflow to dispatch (e.g. wizrd-triage.yml).' + required: true + type: string + filter_kind: + description: 'How to filter incoming issues: assignee | label | none' + required: true + type: string + filter_value: + description: 'Value for filter_kind (login for assignee, label name for label, empty for none).' + required: false + type: string + default: '' + +jobs: + tag-new-issue: + if: ${{ !github.event.issue.pull_request }} + runs-on: ubuntu-latest + permissions: + issues: write + actions: write + contents: read + steps: + - name: Skip if issue already in pipeline + id: gate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + labels=$(gh issue view "${{ github.event.issue.number }}" \ + --repo "${{ github.repository }}" \ + --json labels --jq '.labels[].name') + if printf '%s\n' "$labels" | grep -qE '^wizrd:'; then + echo "already=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "already=false" >> "$GITHUB_OUTPUT" + + - name: Apply filter + if: steps.gate.outputs.already == 'false' + id: filter + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + KIND='${{ inputs.filter_kind }}' + VALUE='${{ inputs.filter_value }}' + case "$KIND" in + none) + echo "matches=true" >> "$GITHUB_OUTPUT" + ;; + label) + labels=$(gh issue view "${{ github.event.issue.number }}" \ + --repo "${{ github.repository }}" \ + --json labels --jq '.labels[].name') + if printf '%s\n' "$labels" | grep -qx "$VALUE"; then + echo "matches=true" >> "$GITHUB_OUTPUT" + else + echo "matches=false" >> "$GITHUB_OUTPUT" + fi + ;; + assignee) + assignees=$(gh issue view "${{ github.event.issue.number }}" \ + --repo "${{ github.repository }}" \ + --json assignees --jq '.assignees[].login') + if printf '%s\n' "$assignees" | grep -qx "$VALUE"; then + echo "matches=true" >> "$GITHUB_OUTPUT" + else + echo "matches=false" >> "$GITHUB_OUTPUT" + fi + ;; + *) + echo "Unknown filter_kind: $KIND" >&2 + exit 1 + ;; + esac + + - name: Apply first-stage label + if: steps.filter.outputs.matches == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh issue edit "${{ github.event.issue.number }}" \ + --repo "${{ github.repository }}" \ + --add-label "${{ inputs.first_stage_label }}" + + - name: Dispatch first stage + if: steps.filter.outputs.matches == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Label-add via GITHUB_TOKEN does not fire `issues.labeled` — + # dispatch the first stage explicitly with the issue number. + gh workflow run "${{ inputs.first_stage_workflow }}" \ + --repo "${{ github.repository }}" \ + --ref "${{ github.event.repository.default_branch }}" \ + --field issue_number="${{ github.event.issue.number }}" From fc1f4884e2bac57f2b61f4fb96d165c5a3a7b7ce Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:28:16 +0200 Subject: [PATCH 03/16] feat(actions): shared stage-transition composite --- .github/actions/wizrd-transition/action.yml | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/actions/wizrd-transition/action.yml diff --git a/.github/actions/wizrd-transition/action.yml b/.github/actions/wizrd-transition/action.yml new file mode 100644 index 0000000..61010c8 --- /dev/null +++ b/.github/actions/wizrd-transition/action.yml @@ -0,0 +1,91 @@ +name: 'Wizrd Stage Transition' +description: 'Removes the current stage label and applies the next label (yolo) or awaiting-approval (human-approval) or needs-judgment (failure). Optionally dispatches the next workflow.' + +inputs: + issue_number: + description: 'Issue or PR number to transition.' + required: true + current_label: + description: 'Label to remove (e.g. wizrd:triage).' + required: true + next_label: + description: 'Label to add on success-yolo. Empty for terminal stages.' + required: false + default: '' + next_stage: + description: 'Semantic stage name to dispatch (e.g. plan, implement). Empty = no dispatch.' + required: false + default: '' + pipeline_workflow: + description: 'Basename of the per-repo pipeline entry workflow (default wizrd-pipeline.yml).' + required: false + default: 'wizrd-pipeline.yml' + mode: + description: 'success-yolo | success-human-approval | success-terminal | failure' + required: true + awaiting_label: + description: 'Label to apply on human-approval success. Default wizrd:awaiting-approval.' + required: false + default: 'wizrd:awaiting-approval' + needs_judgment_label: + description: 'Label to apply on failure. Default wizrd:needs-judgment.' + required: false + default: 'wizrd:needs-judgment' + +runs: + using: composite + steps: + - name: Transition labels + shell: bash + env: + GH_TOKEN: ${{ github.token }} + NUM: ${{ inputs.issue_number }} + CUR: ${{ inputs.current_label }} + NXT: ${{ inputs.next_label }} + AWL: ${{ inputs.awaiting_label }} + NEJ: ${{ inputs.needs_judgment_label }} + MODE: ${{ inputs.mode }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # Always try to remove the current label. Tolerate already-gone. + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/$CUR" 2>/dev/null || true + + case "$MODE" in + success-yolo) + if [ -n "$NXT" ]; then + gh api -X POST "repos/$REPO/issues/$NUM/labels" -f "labels[]=$NXT" + fi + ;; + success-human-approval) + gh api -X POST "repos/$REPO/issues/$NUM/labels" -f "labels[]=$AWL" + ;; + success-terminal) + : # nothing to add + ;; + failure) + gh api -X POST "repos/$REPO/issues/$NUM/labels" -f "labels[]=$NEJ" + ;; + *) + echo "Unknown mode: $MODE" >&2 + exit 1 + ;; + esac + + - name: Dispatch next workflow + if: inputs.mode == 'success-yolo' && inputs.next_stage != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + NUM: ${{ inputs.issue_number }} + STAGE: ${{ inputs.next_stage }} + PWF: ${{ inputs.pipeline_workflow }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # Required: label-add via GITHUB_TOKEN does NOT fire labeled events. + # Route via the per-repo pipeline workflow with the stage input. + gh workflow run "$PWF" \ + --repo "$REPO" \ + --field stage="$STAGE" \ + --field issue_number="$NUM" From ad24ca7fba9ea445230c264df1d84abc5b40a873 Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:29:58 +0200 Subject: [PATCH 04/16] feat(workflows): add triage stage composite --- .github/workflows/wizrd-stage-triage.yml | 92 ++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/wizrd-stage-triage.yml diff --git a/.github/workflows/wizrd-stage-triage.yml b/.github/workflows/wizrd-stage-triage.yml new file mode 100644 index 0000000..4812363 --- /dev/null +++ b/.github/workflows/wizrd-stage-triage.yml @@ -0,0 +1,92 @@ +name: Wizrd Stage — Triage + +on: + workflow_call: + inputs: + issue_number: + description: 'Issue number passed via workflow_dispatch fallback.' + required: false + type: string + default: '' + secrets: + claude_token: + required: true + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + actions: write + id-token: write + concurrency: + group: wizrd-triage-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - name: Discover agent context + id: discover + shell: bash + run: | + set -euo pipefail + AGENT="" + for candidate in .claude/ci-agent-context.md .claude/agent-context.md CLAUDE.md; do + if [ -f "$candidate" ]; then + AGENT="$candidate" + break + fi + done + echo "agent=$AGENT" >> "$GITHUB_OUTPUT" + + - name: Load agent instructions + if: steps.discover.outputs.agent != '' + shell: bash + run: | + { + echo 'INSTRUCTIONS<> "$GITHUB_ENV" + + - name: Run triage + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.claude_token }} + allowed_bots: 'github-actions' + additional_permissions: | + actions: read + claude_args: '--append-system-prompt "${{ env.INSTRUCTIONS }}" --allowedTools "Read,Glob,Grep,Bash"' + prompt: | + Stage: triage. Issue: ${{ github.event.issue.number || inputs.issue_number }}. + + Read the issue title, body, and existing comments via: + gh issue view ${{ github.event.issue.number || inputs.issue_number }} --json title,body,comments + + Post a single triage comment with: + (a) 1-2 sentence restatement of the problem, + (b) classification — severity (p0/p1/p2/p3) and scope (small/medium/large), + (c) whether the issue is ready to plan or blocked on missing information. + + Do NOT write code. Do NOT open branches. Do NOT modify files. + + - name: Transition on success + if: success() + uses: Digitaliko/wizrd-cli/.github/actions/wizrd-transition@v1 + with: + issue_number: ${{ github.event.issue.number || inputs.issue_number }} + current_label: 'wizrd:triage' + next_label: 'wizrd:plan' + next_stage: 'plan' + mode: success-yolo + + - name: Park on failure + if: failure() + uses: Digitaliko/wizrd-cli/.github/actions/wizrd-transition@v1 + with: + issue_number: ${{ github.event.issue.number || inputs.issue_number }} + current_label: 'wizrd:triage' + mode: failure From 4d4aca3803dd6d3afe0dea32c793120b01920339 Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:31:43 +0200 Subject: [PATCH 05/16] feat(workflows): add plan stage (default human-approval) --- .github/workflows/wizrd-stage-plan.yml | 112 +++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .github/workflows/wizrd-stage-plan.yml diff --git a/.github/workflows/wizrd-stage-plan.yml b/.github/workflows/wizrd-stage-plan.yml new file mode 100644 index 0000000..2ba61b4 --- /dev/null +++ b/.github/workflows/wizrd-stage-plan.yml @@ -0,0 +1,112 @@ +name: Wizrd Stage — Plan + +on: + workflow_call: + inputs: + issue_number: + required: false + type: string + default: '' + mode: + description: 'yolo | human-approval (default: human-approval).' + required: false + type: string + default: 'human-approval' + secrets: + claude_token: + required: true + +jobs: + plan: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + actions: write + id-token: write + concurrency: + group: wizrd-plan-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - name: Discover agent context + id: discover + shell: bash + run: | + set -euo pipefail + AGENT="" + for candidate in .claude/ci-agent-context.md .claude/agent-context.md CLAUDE.md; do + if [ -f "$candidate" ]; then + AGENT="$candidate" + break + fi + done + echo "agent=$AGENT" >> "$GITHUB_OUTPUT" + + - name: Load agent instructions + if: steps.discover.outputs.agent != '' + shell: bash + run: | + { + echo 'INSTRUCTIONS<> "$GITHUB_ENV" + + - name: Run plan + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.claude_token }} + allowed_bots: 'github-actions' + additional_permissions: | + actions: read + claude_args: '--append-system-prompt "${{ env.INSTRUCTIONS }}" --allowedTools "Read,Glob,Grep,Bash"' + prompt: | + Stage: plan. Issue: ${{ github.event.issue.number || inputs.issue_number }}. + + Before planning, skim /docs/ and CLAUDE.md for conventions, package layout, + and any subsystem docs that touch the affected area. Cross-reference what + you find so the implementer reuses existing patterns. + + Produce an implementation plan for this issue as a SINGLE issue comment + with these sections: + + Summary — 2-3 sentences describing the change. + Files to change — bullet list of paths with rough line counts. + Approach — short numbered steps. + Risks — anything that could break or surprise a reviewer. + Test plan — how correctness will be verified. + Docs — cite any /docs or CLAUDE.md pages relevant. + + Post via `gh issue comment ${{ github.event.issue.number || inputs.issue_number }} --body-file `. + + Do NOT write code. Do NOT open branches. Do NOT modify files. + + - name: Transition on success (yolo) + if: success() && inputs.mode == 'yolo' + uses: Digitaliko/wizrd-cli/.github/actions/wizrd-transition@v1 + with: + issue_number: ${{ github.event.issue.number || inputs.issue_number }} + current_label: 'wizrd:plan' + next_label: 'wizrd:implement' + next_stage: 'implement' + mode: success-yolo + + - name: Transition on success (human-approval) + if: success() && inputs.mode == 'human-approval' + uses: Digitaliko/wizrd-cli/.github/actions/wizrd-transition@v1 + with: + issue_number: ${{ github.event.issue.number || inputs.issue_number }} + current_label: 'wizrd:plan' + mode: success-human-approval + + - name: Park on failure + if: failure() + uses: Digitaliko/wizrd-cli/.github/actions/wizrd-transition@v1 + with: + issue_number: ${{ github.event.issue.number || inputs.issue_number }} + current_label: 'wizrd:plan' + mode: failure From 72bd69432f50b35a8aea1ba4ea527c3c7d81a43b Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:34:44 +0200 Subject: [PATCH 06/16] feat(workflows): add implement stage with PR handoff --- .github/workflows/wizrd-stage-implement.yml | 146 ++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 .github/workflows/wizrd-stage-implement.yml diff --git a/.github/workflows/wizrd-stage-implement.yml b/.github/workflows/wizrd-stage-implement.yml new file mode 100644 index 0000000..d10a6a0 --- /dev/null +++ b/.github/workflows/wizrd-stage-implement.yml @@ -0,0 +1,146 @@ +name: Wizrd Stage — Implement + +on: + workflow_call: + inputs: + issue_number: + required: false + type: string + default: '' + base_branch: + required: false + type: string + default: 'main' + pipeline_workflow: + description: 'Per-repo pipeline entry workflow basename.' + required: false + type: string + default: 'wizrd-pipeline.yml' + secrets: + claude_token: + required: true + +jobs: + implement: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + actions: write + id-token: write + concurrency: + group: wizrd-implement-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: false + outputs: + pr_number: ${{ steps.detect.outputs.pr_number }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Discover agent context + id: discover + shell: bash + run: | + set -euo pipefail + AGENT="" + for candidate in .claude/ci-agent-context.md .claude/agent-context.md CLAUDE.md; do + if [ -f "$candidate" ]; then + AGENT="$candidate" + break + fi + done + echo "agent=$AGENT" >> "$GITHUB_OUTPUT" + + - name: Load agent instructions + if: steps.discover.outputs.agent != '' + shell: bash + run: | + { + echo 'INSTRUCTIONS<> "$GITHUB_ENV" + + - name: Run implementer + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.claude_token }} + base_branch: ${{ inputs.base_branch }} + branch_prefix: 'issue/' + branch_name_template: 'issue/${{ github.event.issue.number || inputs.issue_number }}-{{description}}' + allowed_bots: 'github-actions' + additional_permissions: | + actions: read + claude_args: '--append-system-prompt "${{ env.INSTRUCTIONS }}" --allowedTools "Read,Glob,Grep,Bash,Edit,Write"' + prompt: | + Stage: implement. Issue: ${{ github.event.issue.number || inputs.issue_number }}. + + Read the most recent plan comment posted by the agent on this issue. + Implement it: branch issue/-, make code changes, + commit with a clear message, open a DRAFT PR including + "closes #${{ github.event.issue.number || inputs.issue_number }}" + in the description. + + Follow conventions in CLAUDE.md / REVIEW_RULES.md / CODE_REVIEW.md + if present. Run lint + typecheck + tests before pushing if available. + + Do NOT merge. Do NOT mark the PR ready-for-review. + + - name: Detect opened PR + id: detect + env: + GH_TOKEN: ${{ github.token }} + NUM: ${{ github.event.issue.number || inputs.issue_number }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + # Find the PR that closes this issue via timeline cross-references. + pr=$(gh issue view "$NUM" \ + --repo "$REPO" \ + --json timelineItems \ + --jq '[.timelineItems[] | select(.__typename == "CrossReferencedEvent") | .source.number] | last // empty' 2>/dev/null || true) + if [ -z "$pr" ]; then + # Fallback: search open PRs for "closes #NUM" in body + pr=$(gh pr list --repo "$REPO" --state open --json number,body \ + --jq "[.[] | select(.body | test(\"closes #$NUM\"; \"i\"))] | .[0].number // empty") + fi + if [ -z "$pr" ]; then + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "No PR detected — implement may have failed silently." >&2 + exit 1 + fi + echo "pr_number=$pr" >> "$GITHUB_OUTPUT" + + - name: Park issue, hand off to PR + if: success() && steps.detect.outputs.pr_number != '' + env: + GH_TOKEN: ${{ github.token }} + NUM: ${{ github.event.issue.number || inputs.issue_number }} + PR: ${{ steps.detect.outputs.pr_number }} + PWF: ${{ inputs.pipeline_workflow }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + # Remove implement label from the issue. + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:implement" 2>/dev/null || true + # Add the review label to the PR (not the issue). + gh api -X POST "repos/$REPO/issues/$PR/labels" -f "labels[]=wizrd:review" + # Dispatch the review stage against the PR number through the per-repo + # pipeline entry workflow. Label-add via GITHUB_TOKEN does NOT fire + # labeled events, so the dispatch is mandatory. + gh workflow run "$PWF" \ + --repo "$REPO" \ + --field stage="review" \ + --field issue_number="$PR" + + - name: Park on failure + if: failure() + uses: Digitaliko/wizrd-cli/.github/actions/wizrd-transition@v1 + with: + issue_number: ${{ github.event.issue.number || inputs.issue_number }} + current_label: 'wizrd:implement' + mode: failure From 37f6b5a84a911700fe0ac97795e7e458bc910587 Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:36:34 +0200 Subject: [PATCH 07/16] feat(workflows): add review-loop entry with verdict-driven control --- .github/workflows/wizrd-stage-review.yml | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .github/workflows/wizrd-stage-review.yml diff --git a/.github/workflows/wizrd-stage-review.yml b/.github/workflows/wizrd-stage-review.yml new file mode 100644 index 0000000..36cb8f7 --- /dev/null +++ b/.github/workflows/wizrd-stage-review.yml @@ -0,0 +1,114 @@ +name: Wizrd Stage — Review (loop entry) + +on: + workflow_call: + inputs: + issue_number: + description: 'PR number (review stage always runs on a PR).' + required: false + type: string + default: '' + max_iterations: + required: false + type: string + default: '3' + pipeline_workflow: + required: false + type: string + default: 'wizrd-pipeline.yml' + secrets: + claude_token: + required: true + +jobs: + review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + actions: write + id-token: write + concurrency: + group: wizrd-review-${{ github.event.pull_request.number || inputs.issue_number }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Run reviewer (via official /code-review plugin) + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.claude_token }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + allowed_bots: 'github-actions' + additional_permissions: | + actions: read + prompt: | + /code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number || inputs.issue_number }} + + Before reviewing, read these if present (first wins): + REVIEW_RULES.md → CODE_REVIEW.md → CLAUDE.md → .claude/ci-agent-context.md + + Post ONE PR review with explicit verdict (approve / request changes / comment). + Do NOT merge. Do NOT push commits. The fix companion will push if changes are requested. + + Use this output format: + ### 🔴 BLOCKING (must fix before merge) + ### 🟡 REQUIRED (should fix before merge) + ### 🟢 ADVISORY (skip section if none) + ### ✅ What's Good + + Cite `file:line` for every code finding. + + - name: Loop control + if: always() + env: + GH_TOKEN: ${{ github.token }} + NUM: ${{ github.event.pull_request.number || inputs.issue_number }} + MAX: ${{ inputs.max_iterations }} + PWF: ${{ inputs.pipeline_workflow }} + REPO: ${{ github.repository }} + REVIEWER_STATUS: ${{ job.status }} + shell: bash + run: | + set -euo pipefail + + # Read iteration counter from PR labels; default 1. + labels=$(gh api "repos/$REPO/issues/$NUM/labels" --jq '.[].name') + iter=$(printf '%s\n' "$labels" | grep -oE '^wizrd:review-iter-[0-9]+$' | head -n1 | grep -oE '[0-9]+$' || echo "") + if [ -z "$iter" ]; then iter=1; fi + + if [ "$REVIEWER_STATUS" != "success" ]; then + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:review-iter-$iter" 2>/dev/null || true + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:review" 2>/dev/null || true + gh api -X POST "repos/$REPO/issues/$NUM/labels" -f "labels[]=wizrd:needs-judgment" + exit 0 + fi + + # Latest PR review verdict + state=$(gh api "repos/$REPO/pulls/$NUM/reviews" --jq '.[].state' | tail -n1 || echo "") + + if [ "$state" = "CHANGES_REQUESTED" ] && [ "$iter" -lt "$MAX" ]; then + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:review-iter-$iter" 2>/dev/null || true + next=$((iter + 1)) + gh api -X POST "repos/$REPO/issues/$NUM/labels" -f "labels[]=wizrd:review-iter-$next" + gh workflow run "$PWF" --repo "$REPO" --field stage="fix" --field issue_number="$NUM" + exit 0 + fi + + if [ "$state" = "CHANGES_REQUESTED" ]; then + # Budget exhausted + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:review-iter-$iter" 2>/dev/null || true + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:review" 2>/dev/null || true + gh api -X POST "repos/$REPO/issues/$NUM/labels" -f "labels[]=wizrd:needs-judgment" + exit 0 + fi + + # Approved / commented / dismissed / empty — exit loop, hand off to verify. + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:review-iter-$iter" 2>/dev/null || true + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:review" 2>/dev/null || true + gh api -X POST "repos/$REPO/issues/$NUM/labels" -f "labels[]=wizrd:verify" + gh workflow run "$PWF" --repo "$REPO" --field stage="verify" --field issue_number="$NUM" || true From 261946cc82904a3b924980b690448622cbc6e7b3 Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:38:43 +0200 Subject: [PATCH 08/16] feat(workflows): add fix companion for review-loop --- .github/workflows/wizrd-stage-fix.yml | 82 +++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/wizrd-stage-fix.yml diff --git a/.github/workflows/wizrd-stage-fix.yml b/.github/workflows/wizrd-stage-fix.yml new file mode 100644 index 0000000..242ca14 --- /dev/null +++ b/.github/workflows/wizrd-stage-fix.yml @@ -0,0 +1,82 @@ +name: Wizrd Stage — Fix (review-loop companion) + +# Dispatched only from wizrd-stage-review.yml. Never triggered directly. + +on: + workflow_call: + inputs: + issue_number: + required: true + type: string + pipeline_workflow: + required: false + type: string + default: 'wizrd-pipeline.yml' + secrets: + claude_token: + required: true + +jobs: + fix: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + actions: write + id-token: write + concurrency: + group: wizrd-fix-${{ inputs.issue_number }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: refs/pull/${{ inputs.issue_number }}/head + + - name: Run fixer + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.claude_token }} + allowed_bots: 'github-actions' + additional_permissions: | + actions: read + claude_args: '--allowedTools "Read,Glob,Grep,Bash,Edit,Write"' + prompt: | + Stage: review (fix pass). PR: ${{ inputs.issue_number }}. + + The most recent PR review requested changes. Read the review body + and any line-level comments, then address each item: edit the + code on the PR branch, commit with a clear message, push. + + Do NOT open a new PR. Do NOT resolve review threads. + Keep scope tight: only fix what the review asked for. + + - name: Dispatch review on success + if: success() + env: + GH_TOKEN: ${{ github.token }} + NUM: ${{ inputs.issue_number }} + PWF: ${{ inputs.pipeline_workflow }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + gh workflow run "$PWF" --repo "$REPO" --field stage="review" --field issue_number="$NUM" + + - name: Park on failure + if: failure() + env: + GH_TOKEN: ${{ github.token }} + NUM: ${{ inputs.issue_number }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + iter=$(gh api "repos/$REPO/issues/$NUM/labels" --jq '.[].name' | \ + grep -oE '^wizrd:review-iter-[0-9]+$' | head -n1 || echo "") + if [ -n "$iter" ]; then + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/$iter" 2>/dev/null || true + fi + gh api -X DELETE "repos/$REPO/issues/$NUM/labels/wizrd:review" 2>/dev/null || true + gh api -X POST "repos/$REPO/issues/$NUM/labels" -f "labels[]=wizrd:needs-judgment" From fe18c2bdcd628d80eec77765194b98e9899b643a Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:40:22 +0200 Subject: [PATCH 09/16] feat(workflows): add verify terminal stage --- .github/workflows/wizrd-stage-verify.yml | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/wizrd-stage-verify.yml diff --git a/.github/workflows/wizrd-stage-verify.yml b/.github/workflows/wizrd-stage-verify.yml new file mode 100644 index 0000000..69d91c4 --- /dev/null +++ b/.github/workflows/wizrd-stage-verify.yml @@ -0,0 +1,72 @@ +name: Wizrd Stage — Verify + +on: + workflow_call: + inputs: + issue_number: + description: 'PR number.' + required: false + type: string + default: '' + secrets: + claude_token: + required: true + +jobs: + verify: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + actions: write + id-token: write + concurrency: + group: wizrd-verify-${{ github.event.pull_request.number || inputs.issue_number }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: refs/pull/${{ github.event.pull_request.number || inputs.issue_number }}/head + + - name: Run verifier + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.claude_token }} + allowed_bots: 'github-actions' + additional_permissions: | + actions: read + claude_args: '--allowedTools "Read,Glob,Grep,Bash,Edit,Write"' + prompt: | + Stage: verify. PR: ${{ github.event.pull_request.number || inputs.issue_number }}. + + Check out the PR branch. Run the project's tests, lint, and typecheck. + Common entry points to try (in order): pnpm test / pnpm lint / pnpm type-check; + npm test / npm run lint / npm run typecheck; bun test. + + Post a single PR comment with the command exit codes and a short + excerpt of any failing output. + + If a check fails and the fix is mechanical (formatting, import order, + obvious typos), commit the fix to the same branch and push. Do not + attempt non-trivial fixes — that's the review-loop's job. + + Mark the PR as ready-for-review when all checks pass: + gh pr ready ${{ github.event.pull_request.number || inputs.issue_number }} + + - name: Transition on success + if: success() + uses: Digitaliko/wizrd-cli/.github/actions/wizrd-transition@v1 + with: + issue_number: ${{ github.event.pull_request.number || inputs.issue_number }} + current_label: 'wizrd:verify' + mode: success-terminal + + - name: Park on failure + if: failure() + uses: Digitaliko/wizrd-cli/.github/actions/wizrd-transition@v1 + with: + issue_number: ${{ github.event.pull_request.number || inputs.issue_number }} + current_label: 'wizrd:verify' + mode: failure From 92f73c06fbead1a83e06bad5d3ec013d9ce8c09b Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:43:12 +0200 Subject: [PATCH 10/16] feat(workflows): add doc-gardening post-merge stage (closes the loop) --- .../workflows/wizrd-stage-doc-gardening.yml | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .github/workflows/wizrd-stage-doc-gardening.yml diff --git a/.github/workflows/wizrd-stage-doc-gardening.yml b/.github/workflows/wizrd-stage-doc-gardening.yml new file mode 100644 index 0000000..132846a --- /dev/null +++ b/.github/workflows/wizrd-stage-doc-gardening.yml @@ -0,0 +1,126 @@ +name: Wizrd Stage — Doc Gardening (post-merge) + +# Terminal stage: runs on every merged PR. No label cascade, no chaining. +# Self-loop guard: skips if the merged PR's head branch starts with +# docs/post-merge-, otherwise doc-gardening would trigger itself forever. + +on: + workflow_call: + inputs: + pr_number: + description: 'PR number of the just-merged PR.' + required: true + type: string + docs_root: + description: 'Root path the agent may edit (default: docs). For L1 wizrds, override to clients//knowledge-base.' + required: false + type: string + default: 'docs' + secrets: + claude_token: + required: true + +jobs: + doc-gardening: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + actions: read + id-token: write + concurrency: + group: wizrd-doc-gardening-${{ inputs.pr_number }} + cancel-in-progress: false + steps: + - name: Self-loop guard + id: guard + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ inputs.pr_number }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + head=$(gh pr view "$PR" --repo "$REPO" --json headRefName --jq .headRefName) + if printf '%s' "$head" | grep -qE '^docs/post-merge-'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping — merged PR is itself a doc-gardening PR ($head)." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v5 + if: steps.guard.outputs.skip == 'false' + with: + fetch-depth: 0 + + - name: Run doc-gardener + if: steps.guard.outputs.skip == 'false' + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.claude_token }} + base_branch: ${{ github.event.repository.default_branch }} + branch_prefix: 'docs/post-merge-' + branch_name_template: 'docs/post-merge-${{ inputs.pr_number }}' + allowed_bots: 'github-actions' + additional_permissions: | + actions: read + claude_args: '--allowedTools "Read,Glob,Grep,Bash,Edit,Write"' + prompt: | + Stage: doc-gardening (post-merge). PR: ${{ inputs.pr_number }}. + + A pull request just merged. Your job: keep `${{ inputs.docs_root }}/` + aligned with the codebase. This is a TERMINAL stage — do NOT + transition labels, do NOT dispatch other workflows, do NOT modify + the merged PR's content. + + 1. Read the merged PR's metadata + diff: + gh pr view ${{ inputs.pr_number }} --json mergeCommit,baseRefName,headRefName,title,body + gh pr diff ${{ inputs.pr_number }} + + 2. Classify the change as SIGNIFICANT or TRIVIAL. + + SIGNIFICANT examples: + - new public API / module / CLI flag / env var + - behavior change visible to users or integrators + - breaking change or new dependency surface + - architecture shift + - new convention worth documenting + + TRIVIAL examples: + - typo / formatting / comment-only + - internal refactor with no public-surface change + - test-only change + - dependency bump with no API impact + + 3. If TRIVIAL: post a single comment on the merged PR: + "doc-gardening skipped — change is internal/trivial." + then exit. No branch, no PR. + + 4. If SIGNIFICANT: + - Read `${{ inputs.docs_root }}/` to learn the doc structure. + - If `${{ inputs.docs_root }}/` does not exist and the change + warrants it, bootstrap with a README indexing per-module pages. + - For monorepos, prefer `${{ inputs.docs_root }}//.md` + over giant catch-all files. Cross-link liberally. + - Create branch `docs/post-merge-${{ inputs.pr_number }}` off + the default branch. Edit ONLY files under `${{ inputs.docs_root }}/`. + - Commit, push, open a NEW draft PR titled + "docs: update for #${{ inputs.pr_number }}" describing what was + added/changed and which merged PR drove it. + + Do NOT touch code outside `${{ inputs.docs_root }}/`. Do NOT reopen + or modify the merged PR beyond the optional skip-comment in step 3. + + - name: Comment on merged PR on failure + if: failure() && steps.guard.outputs.skip == 'false' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ inputs.pr_number }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + gh pr comment "$PR" --repo "$REPO" --body \ + "doc-gardening failed — see workflow run \`${{ github.run_id }}\`. No \`wizrd:needs-judgment\` label applied (post-merge stage has no cascade)." From d7f950887c2ca8775d21ed9fa866cabe69c3b7ed Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:44:32 +0200 Subject: [PATCH 11/16] docs: stability contract for composite public API --- .github/STABILITY.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/STABILITY.md diff --git a/.github/STABILITY.md b/.github/STABILITY.md new file mode 100644 index 0000000..7ca1163 --- /dev/null +++ b/.github/STABILITY.md @@ -0,0 +1,44 @@ +# wizrd-cli composite stability contract + +Everything under `.github/workflows/*.yml` and `.github/actions/*/action.yml` is **public API** — consumed by ~20 Digitaliko repos via `@v1` references. + +## What's additive (stays on @v1) + +- Adding a new composite workflow file. +- Adding a new composite action. +- Adding a new input to an existing composite, with a default that preserves prior behavior. +- Adding a new output to an existing composite. +- Adding a new optional secret to an existing composite (with a sensible fallback if missing). +- Changing the internal implementation of a composite as long as inputs/outputs/secrets stay the same. + +## What's breaking (requires cutting @v2) + +- Removing or renaming a composite workflow or action. +- Removing or renaming an existing input, output, or secret. +- Changing the type of an existing input. +- Changing the default of an existing input in a way that flips behavior. +- Changing required permissions or `runs-on` target. +- Changing the meaning of an existing label transition. + +## Bump rhythm + +- Additive changes: PR + merge, then `git tag -fa v1 && git push --force-with-lease origin v1`. +- Breaking changes: cut `v2` tag, leave `v1` floating where it is. Email/Slack-equivalent the rollout — every consumer migrates on their own timeline. + +## Labels are also public API + +The `wizrd:*` label namespace is contract. Renaming `wizrd:plan` → `wizrd:planning` is breaking. Same `v2` rule applies. + +## Deprecated (removal in Phase G after fleet rollout) + +These composites are superseded by the pipeline stages and will be removed once every repo is on the pipeline: + +- `.github/workflows/wizrd-agent.yml` — reactive `@claude`/`ai: implement` one-shot. Replaced by the full triage → … → verify cascade. +- `.github/actions/wizrd-agent/` — same logic as composite action. Same replacement. +- `.github/workflows/wizrd-review.yml` — one-shot multi-agent review. Folded into `wizrd-stage-review.yml`, which invokes the same `/code-review` plugin inside the review loop. + +Until removed they keep working; the deprecation is a *flag*, not a behavior change. + +## When in doubt + +Treat composite inputs and labels like database columns: easy to add, painful to remove. From a3d6ba4f49792aed90e6023d088cc88a20096cc0 Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:47:34 +0200 Subject: [PATCH 12/16] feat(validate): add check 12 for pipeline adoption --- validate/checks/12-pipeline.sh | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100755 validate/checks/12-pipeline.sh diff --git a/validate/checks/12-pipeline.sh b/validate/checks/12-pipeline.sh new file mode 100755 index 0000000..115d5cf --- /dev/null +++ b/validate/checks/12-pipeline.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Check 12: pipeline adoption +# If the repo has .github/workflows/wizrd-pipeline.yml, validate it: +# - References Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-*.yml@v1 +# - The pipeline labels exist on the repo (when REPO env is set) + +PIPELINE_FILE=".github/workflows/wizrd-pipeline.yml" + +if [ ! -f "$PIPELINE_FILE" ]; then + log_info "12-pipeline: no $PIPELINE_FILE — skipping (repo not in pipeline)" + return 0 +fi + +# 1. Must reference at least one wizrd-stage-*.yml@v1 +if ! grep -qE 'Digitaliko/wizrd-cli/\.github/workflows/wizrd-stage-[a-z-]+\.yml@v1' "$PIPELINE_FILE"; then + log_critical "12-pipeline: $PIPELINE_FILE exists but does not reference any wizrd-stage-*.yml@v1 composite" + return 0 +fi + +# 2. Should reference the tagger (warning only) +if ! grep -qE 'Digitaliko/wizrd-cli/\.github/workflows/wizrd-tagger\.yml@v1' "$PIPELINE_FILE"; then + log_warning "12-pipeline: $PIPELINE_FILE does not reference wizrd-tagger.yml@v1 — issues will not auto-pick up" +fi + +# 3. If REPO env is set, check labels exist +if [ -n "${REPO:-}" ]; then + required=(wizrd:triage wizrd:plan wizrd:implement wizrd:review wizrd:verify wizrd:awaiting-approval wizrd:needs-judgment) + if ! command -v gh >/dev/null 2>&1; then + log_warning "12-pipeline: gh CLI not available — skipping label check on $REPO" + return 0 + fi + existing=$(gh label list --repo "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "") + if [ -z "$existing" ]; then + log_warning "12-pipeline: could not list labels on $REPO — skipping label check" + return 0 + fi + missing=() + for label in "${required[@]}"; do + if ! printf '%s\n' "$existing" | grep -qx "$label"; then + missing+=("$label") + fi + done + if [ "${#missing[@]}" -gt 0 ]; then + log_critical "12-pipeline: $REPO is missing required pipeline labels: ${missing[*]}" + log_info " fix: ./seed-labels.sh L2 $REPO" + return 0 + fi +fi + +log_pass "12-pipeline: pipeline workflow references valid composites" From 05b68982a0eaf409bbca04e0b7405aa835dd2c0c Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:50:18 +0200 Subject: [PATCH 13/16] feat(agent): wizrd pipeline init command --- .../agent/src/commands/pipeline-init.test.ts | 92 ++++++++++++++++ packages/agent/src/commands/pipeline-init.ts | 38 +++++++ .../templates/pipeline/wizrd-pipeline.yml | 102 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 packages/agent/src/commands/pipeline-init.test.ts create mode 100644 packages/agent/src/commands/pipeline-init.ts create mode 100644 packages/agent/templates/pipeline/wizrd-pipeline.yml diff --git a/packages/agent/src/commands/pipeline-init.test.ts b/packages/agent/src/commands/pipeline-init.test.ts new file mode 100644 index 0000000..26e79c7 --- /dev/null +++ b/packages/agent/src/commands/pipeline-init.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runPipelineInit } from "./pipeline-init"; + +let workdir: string; + +beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "wizrd-pipeline-init-")); + mkdirSync(join(workdir, ".github", "workflows"), { recursive: true }); +}); + +afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); +}); + +test("writes wizrd-pipeline.yml referencing every stage composite", async () => { + await runPipelineInit({ + cwd: workdir, + filterKind: "assignee", + filterValue: "Fr33dom91", + baseBranch: "main", + }); + + const out = join(workdir, ".github", "workflows", "wizrd-pipeline.yml"); + expect(existsSync(out)).toBe(true); + + const content = readFileSync(out, "utf-8"); + + expect(content).toContain("Digitaliko/wizrd-cli/.github/workflows/wizrd-tagger.yml@v1"); + for (const stage of ["triage", "plan", "implement", "review", "fix", "verify", "doc-gardening"]) { + expect(content).toContain(`wizrd-stage-${stage}.yml@v1`); + } + expect(content).toContain("filter_kind: assignee"); + expect(content).toContain("filter_value: 'Fr33dom91'"); + expect(content).toContain("base_branch: main"); + expect(content).toContain("docs_root: docs"); +}); + +test("uses default docsRoot 'docs' when not provided", async () => { + await runPipelineInit({ + cwd: workdir, + filterKind: "none", + filterValue: "", + baseBranch: "main", + }); + const content = readFileSync(join(workdir, ".github", "workflows", "wizrd-pipeline.yml"), "utf-8"); + expect(content).toContain("docs_root: docs"); +}); + +test("supports docsRoot override (e.g. knowledge-base for L1)", async () => { + await runPipelineInit({ + cwd: workdir, + filterKind: "none", + filterValue: "", + baseBranch: "main", + docsRoot: "knowledge-base", + }); + const content = readFileSync(join(workdir, ".github", "workflows", "wizrd-pipeline.yml"), "utf-8"); + expect(content).toContain("docs_root: knowledge-base"); +}); + +test("refuses to overwrite an existing wizrd-pipeline.yml without force", async () => { + const out = join(workdir, ".github", "workflows", "wizrd-pipeline.yml"); + writeFileSync(out, "existing\n", "utf-8"); + + await expect( + runPipelineInit({ + cwd: workdir, + filterKind: "none", + filterValue: "", + baseBranch: "main", + }), + ).rejects.toThrow(/already exists/); +}); + +test("overwrites when force: true", async () => { + const out = join(workdir, ".github", "workflows", "wizrd-pipeline.yml"); + writeFileSync(out, "existing\n", "utf-8"); + + await runPipelineInit({ + cwd: workdir, + filterKind: "none", + filterValue: "", + baseBranch: "main", + force: true, + }); + + const content = readFileSync(out, "utf-8"); + expect(content).toContain("Wizrd Pipeline"); +}); diff --git a/packages/agent/src/commands/pipeline-init.ts b/packages/agent/src/commands/pipeline-init.ts new file mode 100644 index 0000000..603ac33 --- /dev/null +++ b/packages/agent/src/commands/pipeline-init.ts @@ -0,0 +1,38 @@ +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export type FilterKind = "assignee" | "label" | "none"; + +export interface PipelineInitOptions { + cwd: string; + filterKind: FilterKind; + filterValue: string; + baseBranch: string; + /** Path doc-gardening is allowed to edit. Default 'docs'. */ + docsRoot?: string; + force?: boolean; +} + +const TEMPLATES_DIR = join(import.meta.dir, "..", "..", "templates", "pipeline"); + +export async function runPipelineInit(opts: PipelineInitOptions): Promise<{ path: string }> { + const outDir = join(opts.cwd, ".github", "workflows"); + mkdirSync(outDir, { recursive: true }); + const outPath = join(outDir, "wizrd-pipeline.yml"); + + if (existsSync(outPath) && !opts.force) { + throw new Error(`${outPath} already exists; pass force: true to overwrite`); + } + + const templatePath = join(TEMPLATES_DIR, "wizrd-pipeline.yml"); + const tpl = readFileSync(templatePath, "utf-8"); + + const rendered = tpl + .replaceAll("__FILTER_KIND__", opts.filterKind) + .replaceAll("__FILTER_VALUE__", opts.filterValue) + .replaceAll("__BASE_BRANCH__", opts.baseBranch) + .replaceAll("__DOCS_ROOT__", opts.docsRoot ?? "docs"); + + writeFileSync(outPath, rendered, "utf-8"); + return { path: outPath }; +} diff --git a/packages/agent/templates/pipeline/wizrd-pipeline.yml b/packages/agent/templates/pipeline/wizrd-pipeline.yml new file mode 100644 index 0000000..c725618 --- /dev/null +++ b/packages/agent/templates/pipeline/wizrd-pipeline.yml @@ -0,0 +1,102 @@ +# Generated by `wizrd pipeline init`. Re-run with --force to regenerate. +name: Wizrd Pipeline + +on: + # Tagger fan-in: any new/edited issue gets considered. + issues: + types: [opened, reopened, labeled, assigned] + # Post-merge fan-in: closes the loop via doc-gardening. + pull_request: + types: [closed] + # Each stage's labeled-trigger lives in its composite — but we need a + # per-repo dispatch entry point so humans can manually start a stage. + workflow_dispatch: + inputs: + stage: + description: 'triage | plan | implement | review | fix | verify | doc-gardening' + required: true + type: choice + options: [triage, plan, implement, review, fix, verify, doc-gardening] + issue_number: + description: 'Issue or PR number' + required: true + type: string + +jobs: + tagger: + if: ${{ github.event_name == 'issues' }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-tagger.yml@v1 + with: + first_stage_label: 'wizrd:triage' + first_stage_workflow: 'wizrd-pipeline.yml' + filter_kind: __FILTER_KIND__ + filter_value: '__FILTER_VALUE__' + + triage: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.stage == 'triage' }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-triage.yml@v1 + with: + issue_number: ${{ inputs.issue_number }} + secrets: + claude_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + plan: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.stage == 'plan' }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-plan.yml@v1 + with: + issue_number: ${{ inputs.issue_number }} + mode: human-approval + secrets: + claude_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + implement: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.stage == 'implement' }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-implement.yml@v1 + with: + issue_number: ${{ inputs.issue_number }} + base_branch: __BASE_BRANCH__ + secrets: + claude_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + review: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.stage == 'review' }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-review.yml@v1 + with: + issue_number: ${{ inputs.issue_number }} + secrets: + claude_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + fix: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.stage == 'fix' }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-fix.yml@v1 + with: + issue_number: ${{ inputs.issue_number }} + secrets: + claude_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + verify: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.stage == 'verify' }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-verify.yml@v1 + with: + issue_number: ${{ inputs.issue_number }} + secrets: + claude_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # Post-merge: doc-gardening closes the loop. Auto-fires on every merged PR. + doc-gardening-auto: + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.merged == true }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-doc-gardening.yml@v1 + with: + pr_number: ${{ github.event.pull_request.number }} + docs_root: __DOCS_ROOT__ + secrets: + claude_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + doc-gardening: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.stage == 'doc-gardening' }} + uses: Digitaliko/wizrd-cli/.github/workflows/wizrd-stage-doc-gardening.yml@v1 + with: + pr_number: ${{ inputs.issue_number }} + docs_root: __DOCS_ROOT__ + secrets: + claude_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} From e1366ac4091d60f09d8557908206050677aa319e Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:53:35 +0200 Subject: [PATCH 14/16] feat(agent): wire pipeline init subcommand --- packages/agent/src/index.ts | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index ed19936..a7614f3 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -68,6 +68,48 @@ if (firstArg && SUBCOMMANDS[firstArg]) { process.exit(exitCode); } +// ---- pipeline subcommand (inline, not delegated) ---- +if (firstArg === "pipeline") { + const verb = args[1]; + if (verb === "init") { + const { runPipelineInit } = await import("./commands/pipeline-init.ts"); + const filterKind = (process.env.WIZRD_FILTER_KIND ?? "assignee") as + | "assignee" + | "label" + | "none"; + const filterValue = process.env.WIZRD_FILTER_VALUE ?? ""; + const baseBranch = process.env.WIZRD_BASE_BRANCH ?? "main"; + const docsRoot = process.env.WIZRD_DOCS_ROOT ?? "docs"; + const force = args.includes("--force"); + try { + const { path } = await runPipelineInit({ + cwd: process.cwd(), + filterKind, + filterValue, + baseBranch, + docsRoot, + force, + }); + console.log(`${GREEN}✓${RESET} Wrote ${path}`); + process.exit(0); + } catch (err) { + console.error(`${BOLD}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + } + console.error(`${BOLD}Error:${RESET} unknown pipeline verb "${verb ?? ""}"`); + console.error(""); + console.error(" Usage:"); + console.error(` ${BOLD}wizrd pipeline init${RESET} [--force] Scaffold .github/workflows/wizrd-pipeline.yml`); + console.error(""); + console.error(" Env vars:"); + console.error(` WIZRD_FILTER_KIND assignee | label | none (default: assignee)`); + console.error(` WIZRD_FILTER_VALUE filter value (default: empty)`); + console.error(` WIZRD_BASE_BRANCH PR base branch (default: main)`); + console.error(` WIZRD_DOCS_ROOT docs root for gardening (default: docs)`); + process.exit(1); +} + // ---- Agent mode: spawn claude with wizrd OS ---- // Parse agent-specific flags From 3520307e7475d48d7f19bc9a71f4dc301e1c98b1 Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 21:56:19 +0200 Subject: [PATCH 15/16] =?UTF-8?q?feat(agent):=20wizrd=20pipeline=20enable?= =?UTF-8?q?=20=E2=80=94=20one-command=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commands/pipeline-enable.test.ts | 83 +++++++ .../agent/src/commands/pipeline-enable.ts | 205 ++++++++++++++++++ packages/agent/src/index.ts | 17 +- 3 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 packages/agent/src/commands/pipeline-enable.test.ts create mode 100644 packages/agent/src/commands/pipeline-enable.ts diff --git a/packages/agent/src/commands/pipeline-enable.test.ts b/packages/agent/src/commands/pipeline-enable.test.ts new file mode 100644 index 0000000..5de8ceb --- /dev/null +++ b/packages/agent/src/commands/pipeline-enable.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectEnableContext } from "./pipeline-enable"; + +let workdir: string; + +beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "wizrd-pipeline-enable-")); +}); + +afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); +}); + +const fakeGh = (opts: { + defaultBranch: string; + nameWithOwner: string; + hasSecret: boolean; +}) => ({ + repoView: async () => ({ + nameWithOwner: opts.nameWithOwner, + defaultBranchRef: { name: opts.defaultBranch }, + }), + secretExists: async (_name: string) => opts.hasSecret, +}); + +test("detects docs/ when present", async () => { + mkdirSync(join(workdir, "docs")); + const ctx = await detectEnableContext({ + cwd: workdir, + gh: fakeGh({ defaultBranch: "main", nameWithOwner: "Test/repo", hasSecret: true }), + }); + expect(ctx.docsRoot).toBe("docs"); + expect(ctx.defaultBranch).toBe("main"); + expect(ctx.repo).toBe("Test/repo"); + expect(ctx.secretPresent).toBe(true); +}); + +test("prefers knowledge-base/ if docs missing", async () => { + mkdirSync(join(workdir, "knowledge-base")); + const ctx = await detectEnableContext({ + cwd: workdir, + gh: fakeGh({ defaultBranch: "main", nameWithOwner: "Test/repo", hasSecret: true }), + }); + expect(ctx.docsRoot).toBe("knowledge-base"); +}); + +test("defaults docsRoot to 'docs' when nothing exists", async () => { + const ctx = await detectEnableContext({ + cwd: workdir, + gh: fakeGh({ defaultBranch: "main", nameWithOwner: "Test/repo", hasSecret: false }), + }); + expect(ctx.docsRoot).toBe("docs"); + expect(ctx.secretPresent).toBe(false); +}); + +test("reads level from CLAUDE.md when present", async () => { + writeFileSync(join(workdir, "CLAUDE.md"), "# Test\n\n## Wizrd Level: L2\n\nstuff\n"); + const ctx = await detectEnableContext({ + cwd: workdir, + gh: fakeGh({ defaultBranch: "main", nameWithOwner: "Test/repo", hasSecret: true }), + }); + expect(ctx.level).toBe("L2"); +}); + +test("falls back to L2 when CLAUDE.md missing", async () => { + const ctx = await detectEnableContext({ + cwd: workdir, + gh: fakeGh({ defaultBranch: "main", nameWithOwner: "Test/repo", hasSecret: true }), + }); + expect(ctx.level).toBe("L2"); +}); + +test("reads L1 from CLAUDE.md", async () => { + writeFileSync(join(workdir, "CLAUDE.md"), "## Wizrd Level: L1\n"); + const ctx = await detectEnableContext({ + cwd: workdir, + gh: fakeGh({ defaultBranch: "main", nameWithOwner: "Test/repo", hasSecret: false }), + }); + expect(ctx.level).toBe("L1"); +}); diff --git a/packages/agent/src/commands/pipeline-enable.ts b/packages/agent/src/commands/pipeline-enable.ts new file mode 100644 index 0000000..09c632a --- /dev/null +++ b/packages/agent/src/commands/pipeline-enable.ts @@ -0,0 +1,205 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { createInterface } from "node:readline"; + +import { runPipelineInit, type FilterKind } from "./pipeline-init.ts"; + +export interface EnableContext { + repo: string; + level: "L0" | "L1" | "L2"; + defaultBranch: string; + docsRoot: string; + secretPresent: boolean; +} + +export interface GhAdapter { + repoView(): Promise<{ nameWithOwner: string; defaultBranchRef: { name: string } }>; + secretExists(name: string): Promise; +} + +const REAL_GH: GhAdapter = { + async repoView() { + const out = execFileSync("gh", ["repo", "view", "--json", "nameWithOwner,defaultBranchRef"], { + encoding: "utf-8", + }); + return JSON.parse(out); + }, + async secretExists(name) { + try { + const out = execFileSync("gh", ["secret", "list", "--json", "name"], { encoding: "utf-8" }); + const list = JSON.parse(out) as Array<{ name: string }>; + return list.some((s) => s.name === name); + } catch { + return false; + } + }, +}; + +export async function detectEnableContext(opts: { + cwd: string; + gh?: GhAdapter; +}): Promise { + const gh = opts.gh ?? REAL_GH; + const view = await gh.repoView(); + const docsRoot = detectDocsRoot(opts.cwd); + const level = detectLevel(opts.cwd); + const secretPresent = await gh.secretExists("CLAUDE_CODE_OAUTH_TOKEN"); + return { + repo: view.nameWithOwner, + level, + defaultBranch: view.defaultBranchRef.name, + docsRoot, + secretPresent, + }; +} + +function detectDocsRoot(cwd: string): string { + for (const candidate of ["docs", "knowledge-base", "documentation"]) { + if (existsSync(join(cwd, candidate))) return candidate; + } + return "docs"; +} + +function detectLevel(cwd: string): "L0" | "L1" | "L2" { + const claudePath = join(cwd, "CLAUDE.md"); + if (!existsSync(claudePath)) return "L2"; + const content = readFileSync(claudePath, "utf-8"); + const match = content.match(/##\s+Wizrd\s+Level:\s*(L0|L1|L2)/i); + if (match) return match[1].toUpperCase() as "L0" | "L1" | "L2"; + return "L2"; +} + +// ─── Orchestration ─────────────────────────────────────────────────── + +export interface EnableOptions { + cwd: string; + filterKind?: FilterKind; + filterValue?: string; + force?: boolean; + /** Skip the seed-labels.sh call (for tests / dry runs). */ + skipSeedLabels?: boolean; + /** Skip the secret prompt + set (for tests / dry runs). */ + skipSecret?: boolean; + /** Skip git branch/commit/push/PR (for tests / dry runs). */ + skipPr?: boolean; + gh?: GhAdapter; +} + +export async function runPipelineEnable(opts: EnableOptions): Promise { + const ctx = await detectEnableContext({ cwd: opts.cwd, gh: opts.gh }); + + if (ctx.level === "L0") { + throw new Error("Pipeline enable refused: L0 (company) repos do not run code pipelines."); + } + + log("✓", `Detected repo: ${ctx.repo} (${ctx.level})`); + log("✓", `Default branch: ${ctx.defaultBranch}`); + log("✓", `Docs root: ${ctx.docsRoot}${existsSync(join(opts.cwd, ctx.docsRoot)) ? " (found)" : " (will be created if needed)"}`); + + const filterKind = opts.filterKind ?? (await promptFilterKind()); + const filterValue = + opts.filterValue ?? (filterKind === "none" ? "" : await promptFilterValue(filterKind)); + + if (!opts.skipSeedLabels) { + log("…", `Seeding labels on ${ctx.repo}...`); + runCmd("bash", [seedLabelsScript(), ctx.level, ctx.repo]); + log("✓", "Labels seeded"); + } + + if (!opts.skipSecret && !ctx.secretPresent) { + const setIt = await promptYesNo( + "CLAUDE_CODE_OAUTH_TOKEN secret not found. Set it now?", + true, + ); + if (setIt) { + runCmd("gh", ["secret", "set", "CLAUDE_CODE_OAUTH_TOKEN", "--repo", ctx.repo]); + log("✓", "Secret set"); + } else { + log("!", "Skipping secret — pipeline will fail until you set it."); + } + } else if (ctx.secretPresent) { + log("✓", "CLAUDE_CODE_OAUTH_TOKEN already set"); + } + + const { path } = await runPipelineInit({ + cwd: opts.cwd, + filterKind, + filterValue, + baseBranch: ctx.defaultBranch, + docsRoot: ctx.docsRoot, + force: opts.force, + }); + log("✓", `Wrote ${path}`); + + if (!opts.skipPr) { + const branch = "feat/wizrd-pipeline"; + runCmd("git", ["checkout", "-b", branch], opts.cwd); + runCmd("git", ["add", ".github/workflows/wizrd-pipeline.yml"], opts.cwd); + runCmd("git", ["commit", "-m", "feat(ci): enable wizrd delivery pipeline"], opts.cwd); + runCmd("git", ["push", "-u", "origin", branch], opts.cwd); + const url = runCmdCapture("gh", [ + "pr", "create", + "--title", "feat(ci): enable wizrd delivery pipeline", + "--body", `Enables the Digitaliko wizrd delivery pipeline (triage → plan → implement → review-loop → verify → doc-gardening). Filter: ${filterKind}${filterValue ? `=${filterValue}` : ""}. Docs root: ${ctx.docsRoot}.`, + ], opts.cwd); + log("✓", `Opened PR: ${url.trim()}`); + } + + console.log(""); + console.log("Next: review the PR, merge, then assign any issue to test the pipeline."); +} + +function seedLabelsScript(): string { + return join(import.meta.dir, "..", "..", "..", "..", "..", "seed-labels.sh"); +} + +function log(symbol: string, msg: string): void { + console.log(`${symbol} ${msg}`); +} + +function runCmd(bin: string, args: string[], cwd?: string): void { + execFileSync(bin, args, { stdio: "inherit", cwd }); +} + +function runCmdCapture(bin: string, args: string[], cwd?: string): string { + return execFileSync(bin, args, { encoding: "utf-8", cwd }); +} + +async function promptFilterKind(): Promise { + console.log("? Pickup filter (who/what triggers the pipeline):"); + console.log(" 1) assignee=Fr33dom91 (Peter — recommended)"); + console.log(" 2) label=auto (opt-in per issue)"); + console.log(" 3) none (every new issue)"); + const ans = (await readLine(" [1-3, default 1]: ")).trim() || "1"; + if (ans === "2") return "label"; + if (ans === "3") return "none"; + return "assignee"; +} + +async function promptFilterValue(kind: FilterKind): Promise { + if (kind === "assignee") { + return (await readLine(" Assignee login [Fr33dom91]: ")).trim() || "Fr33dom91"; + } + if (kind === "label") { + return (await readLine(" Label name [auto]: ")).trim() || "auto"; + } + return ""; +} + +async function promptYesNo(question: string, defaultYes: boolean): Promise { + const suffix = defaultYes ? "[Y/n]" : "[y/N]"; + const ans = (await readLine(` ${question} ${suffix}: `)).trim().toLowerCase(); + if (!ans) return defaultYes; + return ans.startsWith("y"); +} + +function readLine(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(prompt, (a) => { + rl.close(); + resolve(a); + }); + }); +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index a7614f3..1bf2f90 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -71,6 +71,20 @@ if (firstArg && SUBCOMMANDS[firstArg]) { // ---- pipeline subcommand (inline, not delegated) ---- if (firstArg === "pipeline") { const verb = args[1]; + if (verb === "enable") { + const { runPipelineEnable } = await import("./commands/pipeline-enable.ts"); + const force = args.includes("--force"); + try { + await runPipelineEnable({ + cwd: process.cwd(), + force, + }); + process.exit(0); + } catch (err) { + console.error(`${BOLD}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + } if (verb === "init") { const { runPipelineInit } = await import("./commands/pipeline-init.ts"); const filterKind = (process.env.WIZRD_FILTER_KIND ?? "assignee") as @@ -100,7 +114,8 @@ if (firstArg === "pipeline") { console.error(`${BOLD}Error:${RESET} unknown pipeline verb "${verb ?? ""}"`); console.error(""); console.error(" Usage:"); - console.error(` ${BOLD}wizrd pipeline init${RESET} [--force] Scaffold .github/workflows/wizrd-pipeline.yml`); + console.error(` ${BOLD}wizrd pipeline enable${RESET} [--force] One-command bootstrap (recommended)`); + console.error(` ${BOLD}wizrd pipeline init${RESET} [--force] Low-level: just write the workflow file`); console.error(""); console.error(" Env vars:"); console.error(` WIZRD_FILTER_KIND assignee | label | none (default: assignee)`); From 4bf1a2ba73f7b6b6453949de7d04944357d66947 Mon Sep 17 00:00:00 2001 From: filippofilip95 Date: Thu, 11 Jun 2026 22:01:27 +0200 Subject: [PATCH 16/16] docs: document wizrd pipeline subcommand and composite surface --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 536cf1e..08f584b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,8 @@ Spawns `claude` CLI with the wizrd OS system prompt injected via `--append-syste - `wizrd --dry-run` — Show assembled command without executing - `wizrd --verbose` — Show context before launching - `wizrd --model ` — Override model +- `wizrd pipeline enable` — One-command bootstrap: detect repo, seed labels, set OAuth secret, generate `.github/workflows/wizrd-pipeline.yml`, branch + commit + push + PR. Refuses on L0. Env overrides: `WIZRD_FILTER_KIND` / `WIZRD_FILTER_VALUE` / `WIZRD_BASE_BRANCH` / `WIZRD_DOCS_ROOT`. Pass `--force` to overwrite an existing pipeline file. +- `wizrd pipeline init` — Low-level: just write the per-repo workflow file from the template. Same env vars. Skips secret + label-seed + PR steps. Useful for tests and re-rendering. ### `packages/cmd/` — wizrd cmd (fast commands) @@ -87,3 +89,14 @@ bun run packages/cmd/src/index.ts whoami # test cmd - **Templates, not hardcoded prompts** — system prompt assembled from .md files, easy to edit - **Shared lib** — `@wizrd-cli/shared` prevents duplication across packages - **Level detection is the key** — everything (agent, cmd, config, superset) reads `## Wizrd Level` from CLAUDE.md + +## Pipeline composites (delivery harness) + +The `.github/workflows/wizrd-stage-*.yml` + `.github/workflows/wizrd-tagger.yml` + `.github/actions/wizrd-transition/` composites are the **delivery pipeline** — a label-driven cascade (triage → plan → implement → review-loop → verify → doc-gardening) consumed by `@v1` from every Digitaliko repo that opts in. + +- **Public-API contract:** `.github/STABILITY.md` spells out additive vs. breaking changes. Inputs, secret names, label namespace are all contract. +- **Bootstrap:** operators run `wizrd pipeline enable` in their repo; the composites do the rest. +- **Bump rhythm:** additive changes — merge to main, then `git tag -fa v1 && git push --force-with-lease origin v1`. +- **Deprecation:** `wizrd-agent.yml` and `wizrd-review.yml` are superseded by the cascade and will be removed once all repos migrate. + +See `.github/STABILITY.md` for the contract.