diff --git a/.github/workflows/bump-release.yml b/.github/workflows/bump-release.yml index 8ca41d99..103066b0 100644 --- a/.github/workflows/bump-release.yml +++ b/.github/workflows/bump-release.yml @@ -13,14 +13,16 @@ on: permissions: contents: write + pull-requests: write jobs: bump: - name: Bump version and tag + name: Open version-bump PR runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: + fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Install PowerShell @@ -29,8 +31,28 @@ jobs: sudo apt-get update && sudo apt-get install -y powershell fi + - name: Check for changes since last release + id: gate + shell: bash + run: | + LAST_TAG="$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)" + if [ -z "$LAST_TAG" ]; then + echo "No prior version tag found — treating as releasable." + echo "release=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + COUNT="$(git rev-list "${LAST_TAG}..HEAD" --count)" + echo "Last tag: $LAST_TAG — $COUNT commit(s) since." + if [ "${{ github.event_name }}" = "schedule" ] && [ "$COUNT" -eq 0 ]; then + echo "Nothing merged since $LAST_TAG — nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "release=true" >> "$GITHUB_OUTPUT" + fi + - name: Bump version id: bump + if: steps.gate.outputs.release == 'true' shell: pwsh run: | $bump = "${{ github.event.inputs.bump || 'patch' }}" @@ -43,16 +65,94 @@ jobs: 'patch' { $parts[2] = [int]$parts[2] + 1 } } $new = $parts -join '.' + + $existingTag = git tag --list "v$new" + if ($existingTag) { + Write-Error "Tag v$new already exists — aborting to avoid a duplicate release." + exit 1 + } + $existingBranch = git ls-remote --heads origin "release/v$new" + if ($existingBranch) { + Write-Error "Branch release/v$new already exists on origin — aborting." + exit 1 + } + @{ version = $new } | ConvertTo-Json | Set-Content $versionFile Write-Host "Bumped $current -> $new ($bump)" "version=$new" >> $env:GITHUB_OUTPUT - - name: Commit and tag + - name: Promote CHANGELOG Unreleased block + id: changelog + if: steps.gate.outputs.release == 'true' + shell: pwsh + run: | + $version = "${{ steps.bump.outputs.version }}" + $date = (Get-Date).ToString('yyyy-MM-dd') + $path = "CHANGELOG.md" + $lines = Get-Content $path + + $anchor = ($lines | Select-String -Pattern '^## \[Unreleased\]' -SimpleMatch:$false | Select-Object -First 1) + if (-not $anchor) { + Write-Error "No '## [Unreleased]' section found in $path" + exit 1 + } + $idx = $anchor.LineNumber - 1 + $bodyStart = $idx + 1 + $next = $lines.Length + for ($i = $bodyStart; $i -lt $lines.Length; $i++) { + if ($lines[$i] -match '^## \[') { $next = $i; break } + } + + $notes = ($lines[$bodyStart..($next - 1)] -join "`n").Trim() + $hasContent = $notes -split "`n" | Where-Object { $_ -notmatch '^\s*(#{2,6}\s|$)' } + if (-not $hasContent) { + $notes = "Maintenance release." + } + Set-Content -Path "release-notes.md" -Value $notes + + $scaffold = @( + '## [Unreleased]', + '', + '### Added', + '', + '### Changed', + '', + '### Fixed', + '', + '### Removed', + '', + "## [$version] - $date" + ) + + $rebuilt = @() + $rebuilt += $lines[0..($idx - 1)] + $rebuilt += $scaffold + $rebuilt += $lines[$bodyStart..($lines.Length - 1)] + Set-Content -Path $path -Value $rebuilt + Write-Host "Promoted [Unreleased] -> [$version] - $date" + + - name: Create branch, commit, open PR + if: steps.gate.outputs.release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash run: | + set -euo pipefail VERSION="${{ steps.bump.outputs.version }}" + BRANCH="release/v${VERSION}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add version.json - git commit -m "Bump version to v${VERSION}" - git tag "v${VERSION}" - git push origin main --tags + git checkout -b "$BRANCH" + git add version.json CHANGELOG.md + git commit -m "chore(release): bump version to v${VERSION}" + git push -u origin "$BRANCH" + { + cat release-notes.md + printf '\n\n---\n_Merging this PR cuts v%s — **Release & Publish** runs automatically on merge._\n' "$VERSION" + } > pr-body.md + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore(release): v${VERSION}" \ + --body-file pr-body.md \ + --label no-issue diff --git a/.github/workflows/notify-pr.yml b/.github/workflows/notify-pr.yml index 1065717b..43b5b4c6 100644 --- a/.github/workflows/notify-pr.yml +++ b/.github/workflows/notify-pr.yml @@ -1,7 +1,7 @@ name: "Notify: PR activity → #pull-requests" on: - pull_request: + pull_request_target: types: [opened, ready_for_review, closed] permissions: diff --git a/.github/workflows/release-autoclose.yml b/.github/workflows/release-autoclose.yml new file mode 100644 index 00000000..097a6064 --- /dev/null +++ b/.github/workflows/release-autoclose.yml @@ -0,0 +1,62 @@ +name: "Auto-close linked issues (release branches)" + +on: + # pull_request_target: token can close issues from fork PRs; no checkout. + pull_request_target: + types: [closed] + branches: + - 'releases/**' + +permissions: + issues: write + +jobs: + close-linked: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Close linked issues + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const base = pr.base.ref; + const body = pr.body || ''; + + // Same keywords as pr-link-check.yml; bare same-repo #N only. + const re = /(?:closes|fixes|resolves):?\s+#(\d+)/gi; + const numbers = [...new Set([...body.matchAll(re)].map(m => Number(m[1])))]; + + if (numbers.length === 0) { + core.info('No linked issues found in PR body.'); + return; + } + + for (const issue_number of numbers) { + try { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + }); + if (issue.pull_request) { core.info(`#${issue_number} is a PR, skipping.`); continue; } + if (issue.state === 'closed') { core.info(`#${issue_number} already closed, skipping.`); continue; } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body: `Closed via #${pr.number} (merged to \`${base}\`).`, + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + state: 'closed', + state_reason: 'completed', + }); + core.info(`Closed #${issue_number}.`); + } catch (err) { + core.warning(`Failed to close #${issue_number}: ${err.message}`); + } + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b9ad011..10530e0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,9 @@ name: Release & Publish on: push: - tags: ['v*'] + branches: [main] + paths: ['version.json'] + workflow_dispatch: permissions: contents: write @@ -20,7 +22,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install PowerShell (Linux) if: runner.os == 'Linux' @@ -63,11 +65,6 @@ jobs: with: node-version: '20' - - name: Install dotbot globally - shell: pwsh - run: | - pwsh -NoProfile -ExecutionPolicy Bypass -File install.ps1 - - name: Run tests (layers 1-3) shell: pwsh run: | @@ -83,7 +80,7 @@ jobs: sha256_tar: ${{ steps.hashes.outputs.sha256_tar }} sha256_zip: ${{ steps.hashes.outputs.sha256_zip }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install PowerShell run: | @@ -91,49 +88,83 @@ jobs: sudo apt-get update && sudo apt-get install -y powershell fi - - name: Extract version from tag + - name: Resolve version from version.json id: version + shell: bash run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="$(pwsh -NoProfile -Command '(Get-Content version.json | ConvertFrom-Json).version')" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing v$VERSION" + + - name: Skip if release already exists + id: exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + if gh release view "v${VERSION}" >/dev/null 2>&1 || git ls-remote --exit-code --tags origin "v${VERSION}" >/dev/null 2>&1; then + echo "Release/tag v${VERSION} already exists — skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi - - name: Validate version matches version.json + - name: Extract release notes from CHANGELOG + if: steps.exists.outputs.skip != 'true' shell: pwsh run: | - $expected = "${{ steps.version.outputs.version }}" - $actual = (Get-Content version.json | ConvertFrom-Json).version - if ($expected -ne $actual) { - Write-Error "Tag version ($expected) does not match version.json ($actual)" - exit 1 + $version = "${{ steps.version.outputs.version }}" + $path = "CHANGELOG.md" + $notes = "" + if (Test-Path $path) { + $lines = Get-Content $path + $start = -1 + for ($i = 0; $i -lt $lines.Length; $i++) { + if ($lines[$i] -match "^## \[$([regex]::Escape($version))\]") { $start = $i + 1; break } + } + if ($start -ge 0) { + $end = $lines.Length + for ($i = $start; $i -lt $lines.Length; $i++) { + if ($lines[$i] -match '^## \[') { $end = $i; break } + } + $notes = ($lines[$start..($end - 1)] -join "`n").Trim() + $hasContent = $notes -split "`n" | Where-Object { $_ -notmatch '^\s*(#{2,6}\s|$)' } + if (-not $hasContent) { $notes = "Maintenance release." } + } } + Set-Content -Path "release-notes.md" -Value $notes - name: Setup Node.js + if: steps.exists.outputs.skip != 'true' uses: actions/setup-node@v4 with: node-version: '20' - name: Build Studio UI - working-directory: studio-ui + if: steps.exists.outputs.skip != 'true' + working-directory: src/studio-ui run: npm ci && npm run build - name: Build archives + if: steps.exists.outputs.skip != 'true' run: | VERSION="${{ steps.version.outputs.version }}" STAGING="${ARCHIVE_PREFIX}-${VERSION}" mkdir "$STAGING" - cp -r install.ps1 install-remote.ps1 scripts/ workflows/ stacks/ LICENSE README.md \ - version.json dotbot.psd1 dotbot.psm1 "$STAGING/" - # Include studio-ui runtime files (no Node source) - mkdir -p "$STAGING/studio-ui/" - cp studio-ui/server.ps1 studio-ui/StudioAPI.psm1 "$STAGING/studio-ui/" - cp -r studio-ui/static/ "$STAGING/studio-ui/static/" + cp -r bin src content bootstrap.ps1 version.json LICENSE README.md "$STAGING/" + + rm -rf "$STAGING/src/studio-ui" + mkdir -p "$STAGING/src/studio-ui" + cp src/studio-ui/server.ps1 src/studio-ui/StudioAPI.psm1 "$STAGING/src/studio-ui/" + cp -r src/studio-ui/static "$STAGING/src/studio-ui/" tar czf "${STAGING}.tar.gz" "$STAGING" zip -r "${STAGING}.zip" "$STAGING" - name: Compute SHA256 id: hashes + if: steps.exists.outputs.skip != 'true' run: | VERSION="${{ steps.version.outputs.version }}" echo "sha256_tar=$(sha256sum ${ARCHIVE_PREFIX}-${VERSION}.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" @@ -142,66 +173,35 @@ jobs: sha256sum ${ARCHIVE_PREFIX}-${VERSION}.zip > ${ARCHIVE_PREFIX}-${VERSION}.zip.sha256 - name: Create GitHub Release + if: steps.exists.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ steps.version.outputs.version }}" + if grep -q '[^[:space:]]' release-notes.md 2>/dev/null; then + cat release-notes.md > final-notes.md + { + printf '\n## Install\n\n' + printf '```\n' + printf '# Homebrew (macOS / Linux)\n' + printf 'brew install andresharpe/dotbot/dotbot\n\n' + printf '# Scoop (Windows)\n' + printf 'scoop bucket add dotbot https://github.com/andresharpe/scoop-dotbot\n' + printf 'scoop install dotbot\n\n' + printf '# From source\n' + printf 'git clone https://github.com/andresharpe/dotbot ~/dotbot\n' + printf 'pwsh ~/dotbot/bootstrap.ps1\n' + printf '```\n' + } >> final-notes.md + NOTES_ARGS=(--notes-file final-notes.md) + else + NOTES_ARGS=(--generate-notes) + fi gh release create "v${VERSION}" \ --title "v${VERSION}" \ - --generate-notes \ + --target "${GITHUB_SHA}" \ + "${NOTES_ARGS[@]}" \ "${ARCHIVE_PREFIX}-${VERSION}.tar.gz" \ "${ARCHIVE_PREFIX}-${VERSION}.zip" \ "${ARCHIVE_PREFIX}-${VERSION}.tar.gz.sha256" \ "${ARCHIVE_PREFIX}-${VERSION}.zip.sha256" - - # ── Publish to PowerShell Gallery ────────────────────────── - publish-psgallery: - name: Publish to PSGallery - needs: release - runs-on: ubuntu-latest - if: vars.ENABLE_PSGALLERY == 'true' - steps: - - uses: actions/checkout@v4 - - - name: Install PowerShell - run: | - if ! command -v pwsh &> /dev/null; then - sudo apt-get update && sudo apt-get install -y powershell - fi - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Build Studio UI - working-directory: studio-ui - run: npm ci && npm run build - - - name: Prepare module - shell: pwsh - run: | - $version = "${{ needs.release.outputs.version }}" - $staging = "publish-staging/Dotbot" - New-Item -ItemType Directory -Path $staging -Force - Copy-Item dotbot.psd1, dotbot.psm1, install.ps1, install-remote.ps1, LICENSE, README.md -Destination $staging - Copy-Item -Recurse scripts, workflows, stacks -Destination $staging - # Include studio-ui runtime files - $studioStaging = Join-Path $staging 'studio-ui' - New-Item -ItemType Directory -Path $studioStaging -Force - Copy-Item studio-ui/server.ps1, studio-ui/StudioAPI.psm1 -Destination $studioStaging - Copy-Item -Recurse studio-ui/static -Destination (Join-Path $studioStaging 'static') - # Rename to PascalCase for PSGallery convention - Rename-Item "$staging/dotbot.psd1" "Dotbot.psd1" - Rename-Item "$staging/dotbot.psm1" "Dotbot.psm1" - $psd1 = Get-Content "$staging/Dotbot.psd1" -Raw - $psd1 = $psd1 -replace "RootModule = 'dotbot.psm1'", "RootModule = 'Dotbot.psm1'" - $psd1 = $psd1 -replace "ModuleVersion = '.*?'", "ModuleVersion = '$version'" - $psd1 | Set-Content "$staging/Dotbot.psd1" -NoNewline - - - name: Publish - shell: pwsh - env: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - run: | - Publish-Module -Path "publish-staging/Dotbot" -NuGetApiKey $env:PSGALLERY_API_KEY -Verbose diff --git a/.github/workflows/studio-ui.yml b/.github/workflows/studio-ui.yml index f4ad3611..c7ba3ee4 100644 --- a/.github/workflows/studio-ui.yml +++ b/.github/workflows/studio-ui.yml @@ -2,13 +2,15 @@ name: Studio UI Build & Validate on: push: - branches: [main] + branches: [main, 'releases/**'] paths: - 'src/studio-ui/**' + - 'src/shared/css/**' pull_request: - branches: [main] + branches: [main, 'releases/**'] paths: - 'src/studio-ui/**' + - 'src/shared/css/**' workflow_dispatch: jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 30de7e67..ef457d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to dotbot are documented in this file. The format follows [K ## [Unreleased] +### Added + +### Changed + +### Fixed + +### Removed + +## [4.0.2] - 2026-07-09 + +### Added + +### Changed + +### Fixed + +### Removed + +## [4.0.1] - 2026-07-02 + ### Added - **`bootstrap.ps1`** at the repo root — the one-time install step. Drops the `bin/shim/dotbot*` PATH shim into `~/.local/bin` (Linux/macOS) or `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows). Refuses PowerShell 5.1; never sets `$env:DOTBOT_HOME` for the user (design decision D4). Honours `-ShimDir` and `-Force`. - **`dotbot status`** subcommand reporting resolved `DOTBOT_HOME`, framework branch + short SHA + dirty flag, version, user-settings path, and the active project's workflow / provider / stacks. `--json` emits a stable shape for CI scripts and the dashboard. diff --git a/README.md b/README.md index 6fcdad19..5c3b3400 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ dotbot wraps AI-assisted coding in a managed, transparent workflow where every s ### Multi-workflow platform - **Workflow-driven pipelines** - Define multi-step pipelines in `workflow.json` manifests with tasks, dependencies, form configuration, MCP servers, and environment requirements. A project can have multiple workflows installed simultaneously, each run, re-run, and stopped independently. -- **Typed task system** - Tasks can be `prompt` (AI-executed), `script` (PowerShell, no LLM), `mcp` (tool call), `task_gen` (generates sub-tasks dynamically), or `prompt_template` (AI with a workflow-specific prompt). Script, MCP, and task_gen tasks bypass the AI entirely - they auto-promote past analysis, skip worktree isolation, and skip verification hooks. This enables deterministic pipeline stages within AI-orchestrated workflows. +- **Typed task system** - Tasks can be `prompt` (AI-executed), `script` (PowerShell, no LLM), `mcp` (tool call), `task_gen` (generates sub-tasks dynamically), or `prompt_template` (AI with a workflow-specific prompt). Script, MCP, and task_gen tasks bypass the provider session but still execute in the task worktree and complete through the normal task status transition, including verification hooks. This enables deterministic pipeline stages within AI-orchestrated workflows. - **Enterprise registries** - Teams publish workflows, stacks, tools, and skills in git-hosted or local registries. `dotbot registry add` links a registry (private or public); `dotbot init -Workflow registry:name` installs from it. Registries are validated against a `registry.json` manifest with version compatibility checks and auth-failure hints for GitHub, Azure DevOps, and GitLab. - **Workflows and stacks** - **Workflows** (e.g. `start-from-jira`) define operational pipelines - what dotbot does. **Stacks** (e.g. `dotnet`, `dotnet-blazor`) add tech-specific skills, hooks, and MCP tools - what tech the project uses. Stacks compose additively with `extends` chains. Settings deep-merge across `default -> workflows -> stacks`. ### Execution engine -- **Two-phase execution** - Analysis resolves ambiguity, identifies files, and builds a context package. Implementation consumes that package and writes code. Tasks flow: `todo -> analysing -> analysed -> in-progress -> done`. +- **Single-session execution** - Prompt tasks run discovery, planning, implementation, verification, and completion inside one provider session using `100-single-session-task.md`. The core flow is `todo -> in-progress -> done`, with side paths for `needs-input`, `needs-review`, `failed`, `skipped`, and `cancelled`. - **Per-task git worktree isolation** - Each task runs in its own worktree on an isolated branch, squash-merged back to main on completion. - **Per-task model selection** - Tasks can specify a model (e.g. Sonnet for simple tasks, Opus for complex ones) that overrides the process-level default. Use cheaper models where they suffice to reduce token spend. - **Multi-slot concurrent execution** - The workflow engine runs multiple tasks from the same workflow in parallel with slot-aware locking, shortening wall-clock time for large task queues. @@ -222,9 +222,9 @@ The runtime resolves framework content lazily: `/content/// ## MCP Tools -The dotbot MCP server exposes 33 tools, auto-discovered from `systems/mcp/tools/`: +The dotbot MCP server exposes 31 tools, auto-discovered from `src/mcp/tools/`: -**Task Management** (15): `task_create`, `task_create_bulk`, `task_get_next`, `task_get_context`, `task_list`, `task_get_stats`, `task_mark_todo`, `task_mark_analysing`, `task_mark_analysed`, `task_mark_in_progress`, `task_mark_done`, `task_mark_needs_input`, `task_mark_skipped`, `task_answer_question`, `task_approve_split` +**Task Management** (10): `task_create`, `task_create_bulk`, `task_get`, `task_get_context`, `task_get_next`, `task_list`, `task_mark_needs_review`, `task_set_status`, `task_submit_review`, `task_update` **Decision Tracking** (7): `decision_create`, `decision_get`, `decision_list`, `decision_update`, `decision_mark_accepted`, `decision_mark_deprecated`, `decision_mark_superseded` @@ -232,6 +232,8 @@ The dotbot MCP server exposes 33 tools, auto-discovered from `systems/mcp/tools/ **Plans** (3): `plan_create`, `plan_get`, `plan_update` +**Workflow** (3): `workflow_get`, `workflow_list`, `workflow_start` + **Steering**: `steering_heartbeat` **Development**: `dev_start`, `dev_stop` @@ -248,7 +250,7 @@ Four-layer test pyramid with ~500 assertions: |-------|---------------|-------------| | 1 - Structure | Syntax validation, module exports, workflow manifest parsing, task creation, condition evaluation, multi-workflow isolation | None | | 2 - Components | MCP tool lifecycle, task types, decision tracking, provider CLI, notification client, workflow integration, UI server startup | None | -| 3 - Mock Provider | Analysis/execution flows with mock Claude CLI and stream parsing | None | +| 3 - Mock Provider | Workflow execution flows with mock Claude CLI and stream parsing | None | | 4 - E2E | Full end-to-end with real AI provider API | API key | ```powershell diff --git a/bin/dotbot.ps1 b/bin/dotbot.ps1 index 2e8fef8a..a1ec3384 100755 --- a/bin/dotbot.ps1 +++ b/bin/dotbot.ps1 @@ -205,6 +205,11 @@ function Get-RequestedDashboardPort { } function Invoke-Init { + $registryManagerModule = Join-Path $ScriptsDir "RegistryManager.psm1" + if (Test-Path $registryManagerModule) { + Import-Module $registryManagerModule -Force -DisableNameChecking + Update-StaleRegistries -DotbotBase $DotbotBase + } $initScript = Join-Path $ScriptsDir "init-project.ps1" if (Test-Path $initScript) { if ($SplatArgs.Count -gt 0) { @@ -509,6 +514,11 @@ function Invoke-Install { } function Invoke-Run { + $registryManagerModule = Join-Path $ScriptsDir "RegistryManager.psm1" + if (Test-Path $registryManagerModule) { + Import-Module $registryManagerModule -Force -DisableNameChecking + Update-StaleRegistries -DotbotBase $DotbotBase + } $runScript = Join-Path $ScriptsDir 'workflow-run.ps1' $invocation = Get-WorkflowRunInvocation -RunArgs $SubArgs if ($invocation.WorkflowName -and (Test-Path $runScript)) { @@ -657,7 +667,7 @@ function Invoke-Go { } finally { Pop-Location if ($runtimeStartedHere -and $runtimeStart -and $runtimeStart.listener) { - Stop-DotbotRuntime -BotRoot $botDir -Listener $runtimeStart.listener -ErrorAction SilentlyContinue + Stop-DotbotRuntime -BotRoot $botDir -Listener $runtimeStart.listener -ControlPlaneRegistration $runtimeStart.control_plane -EventConsumer $runtimeStart.events_consumer -ErrorAction SilentlyContinue } } } diff --git a/content/settings/settings.default.json b/content/settings/settings.default.json index 47b19e7b..ca2f39af 100644 --- a/content/settings/settings.default.json +++ b/content/settings/settings.default.json @@ -104,5 +104,21 @@ "description": "New document in product briefing" } ] + }, + "events": { + "enabled": true, + "webhooks": { + "enabled": false, + "endpoints": [ + { + "url": "https://hooks.example.com/dotbot", + "events": ["task.*", "workflow.*"], + "secret": "" + } + ] + }, + "mothership": { + "enabled": false + } } } diff --git a/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/script.ps1 b/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/script.ps1 index 4d25d4eb..a0096860 100644 --- a/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/script.ps1 +++ b/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/script.ps1 @@ -1,3 +1,48 @@ +# Extract a Jira/ADO work-item key from a jira-context.md document. The +# Fetch-Jira-Context step is supposed to emit a canonical `| Jira Key | KEY |` +# row, but agents have been observed free-forming the metadata table with other +# labels (`Primary Jira Keys`, `Parent Epic`, ...). Try the canonical row first, +# then known label variants, then the H1 title, then any key anywhere -- so a +# minor table-format drift no longer kills the entire code-execution phase. +# Matching is case-SENSITIVE (-cmatch): real keys are upper-case, and an +# insensitive match would treat tokens like `utf-8` / `sha-1` as keys. +function Get-RepoCloneJiraKey { + param([AllowEmptyString()][string]$Content) + + if ([string]::IsNullOrWhiteSpace($Content)) { return $null } + $key = '[A-Z]{2,10}-\d+' + + # (a) canonical row + if ($Content -cmatch "\|\s*Jira Key\s*\|\s*($key)") { return $matches[1] } + # (b) known label variants (first key in the cell) + if ($Content -cmatch "\|\s*(?:Primary Jira Keys?|Parent Epic|Programme[^|]*)\s*\|\s*($key)") { return $matches[1] } + # (c) the H1 title (e.g. "# Jira Context: ENHANCE-9851 ...") + foreach ($line in ($Content -split "`n")) { + if ($line -match '^\s*#\s' -and $line -cmatch "($key)") { return $matches[1] } + } + # (d) last resort: first key-shaped token anywhere + if ($Content -cmatch "($key)") { return $matches[1] } + + return $null +} + +# A directory that merely exists is not a usable clone: a leftover empty gitlink +# (a 160000 tree entry with no working tree) or a dangling .git pointer leaves a +# dir that must be re-cloned. Treat a clone as complete when it is a real work +# tree with an origin remote -- which covers both populated clones and a valid +# clone of an empty (commitless) remote, without requiring a resolvable HEAD or +# tracked files (an empty-but-valid clone has neither yet, and must not be +# wrongly reclaimed). +function Test-RepoCloneComplete { + param([Parameter(Mandatory)][string]$ClonePath) + + if (-not (Test-Path (Join-Path $ClonePath '.git'))) { return $false } + $null = & git -C $ClonePath rev-parse --is-inside-work-tree 2>$null + if ($LASTEXITCODE -ne 0) { return $false } + $remote = & git -C $ClonePath config --get remote.origin.url 2>$null + return [bool]$remote +} + function Invoke-RepoClone { param([hashtable]$Arguments) @@ -7,6 +52,17 @@ function Invoke-RepoClone { if (-not $project) { throw "project is required" } if (-not $repo) { throw "repo is required" } + # $project/$repo are caller-supplied (MCP tool input). $repo becomes a + # filesystem path ($clonePath) that is later force-deleted on a re-clone, so + # reject anything that could escape the repos/ directory: path separators, + # '..' traversal, or a bare '.'/'..'. (Spaces and other chars are allowed -- + # ADO names permit them -- only traversal is blocked.) + foreach ($seg in @(@{ name = 'repo'; value = $repo }, @{ name = 'project'; value = $project })) { + if ($seg.value -match '[\\/]' -or $seg.value -match '\.\.' -or $seg.value -in @('.', '..')) { + throw "Invalid $($seg.name) name (path traversal not allowed): '$($seg.value)'" + } + } + # --------------------------------------------------------------------------- # Load .env.local for credentials # --------------------------------------------------------------------------- @@ -34,10 +90,7 @@ function Invoke-RepoClone { $initiativePath = Join-Path $global:DotbotProjectRoot ".bot/workspace/product/briefing/jira-context.md" $jiraKey = $null if (Test-Path $initiativePath) { - $content = Get-Content $initiativePath -Raw - if ($content -match '\|\s*Jira Key\s*\|\s*([A-Z]{2,10}-\d+)') { - $jiraKey = $matches[1] - } + $jiraKey = Get-RepoCloneJiraKey -Content (Get-Content $initiativePath -Raw) } # Read branch prefix from the merged settings chain (defaults + ~/dotbot + .control) @@ -66,25 +119,60 @@ function Invoke-RepoClone { } if (Test-Path $clonePath) { - return @{ - success = $true - path = $clonePath - default_branch = (git -C $clonePath symbolic-ref refs/remotes/origin/HEAD 2>$null) -replace 'refs/remotes/origin/', '' - working_branch = $workingBranch - message = "Repository already cloned at $clonePath" - already_cloned = $true + if (Test-RepoCloneComplete -ClonePath $clonePath) { + return @{ + success = $true + path = $clonePath + default_branch = (git -C $clonePath symbolic-ref refs/remotes/origin/HEAD 2>$null) -replace 'refs/remotes/origin/', '' + working_branch = $workingBranch + message = "Repository already cloned at $clonePath" + already_cloned = $true + } + } + # Path exists but is not a usable clone. Only reclaim it when it is + # genuinely empty (the observed failure: a leftover empty gitlink dir with + # no working tree). A non-empty directory might be a real repo that merely + # failed a transient git check, so refuse to force-delete it. + $hasContent = @(Get-ChildItem -LiteralPath $clonePath -Force -ErrorAction SilentlyContinue).Count -gt 0 + if ($hasContent) { + return @{ + success = $false + error_type = "incomplete_clone" + message = "Path '$clonePath' exists but is not a complete clone (no resolvable HEAD / tracked files). Remove or repair it, then retry." + path = $clonePath + } } + Remove-Item -LiteralPath $clonePath -Recurse -Force -ErrorAction SilentlyContinue } - # Build clone URL with PAT authentication - $orgHost = ($adoOrgUrl -replace 'https?://', '') - $cloneUrl = "https://$($adoPat)@$orgHost/$project/_git/$repo" + # Authenticate with a host-scoped http.extraHeader injected through the + # GIT_CONFIG_* environment (git >= 2.31). The PAT is never embedded in the + # clone URL, so it does not appear in process arguments and is not persisted + # to the cloned repo's .git/config (remote.origin.url stays credential-free). + $orgHost = ($adoOrgUrl -replace 'https?://', '').TrimEnd('/') + $cloneUrl = "https://$orgHost/$project/_git/$repo" + $basicToken = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(":$adoPat")) + + # Append the auth header at the next free GIT_CONFIG_* slot, not slot 0: + # forcing GIT_CONFIG_COUNT=1 would make git ignore caller-injected config + # (e.g. corporate proxy / custom-CA) during the clone. Restored in finally. + $priorCount = $env:GIT_CONFIG_COUNT + $slot = if ($priorCount -match '^\d+$') { [int]$priorCount } else { 0 } + $keyVar = "GIT_CONFIG_KEY_$slot" + $valVar = "GIT_CONFIG_VALUE_$slot" + $priorKey = [Environment]::GetEnvironmentVariable($keyVar, 'Process') + $priorVal = [Environment]::GetEnvironmentVariable($valVar, 'Process') + + $env:GIT_CONFIG_COUNT = [string]($slot + 1) + [Environment]::SetEnvironmentVariable($keyVar, "http.https://$orgHost/.extraHeader", 'Process') + [Environment]::SetEnvironmentVariable($valVar, "Authorization: Basic $basicToken", 'Process') try { $cloneOutput = & git clone $cloneUrl $clonePath 2>&1 if ($LASTEXITCODE -ne 0) { $errorMsg = ($cloneOutput | Out-String).Trim() $errorMsg = $errorMsg -replace [regex]::Escape($adoPat), '***' + $errorMsg = $errorMsg -replace [regex]::Escape($basicToken), '***' $errorType = if ($errorMsg -match 'Authentication failed|401|403') { "authentication_failed" } elseif ($errorMsg -match 'not found|does not exist|404') { "repo_not_found" } @@ -105,6 +193,12 @@ function Invoke-RepoClone { message = "Failed to clone $repo from $project`: $_" path = $null } + } finally { + # Restore the original count + the slot we appended into, verbatim + # (including unset → $null, which removes the entry). + [Environment]::SetEnvironmentVariable('GIT_CONFIG_COUNT', $priorCount, 'Process') + [Environment]::SetEnvironmentVariable($keyVar, $priorKey, 'Process') + [Environment]::SetEnvironmentVariable($valVar, $priorVal, 'Process') } # Detect default branch diff --git a/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/test.ps1 b/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/test.ps1 new file mode 100644 index 00000000..a9ab4fee --- /dev/null +++ b/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/test.ps1 @@ -0,0 +1,121 @@ +# Test repo-clone tool helpers (Jira-key extraction + clone-completeness guard). +# The clone itself needs live ADO credentials, so it is not exercised here; these +# tests cover the pure parsing and the local git-state guard. + +Import-Module $env:DOTBOT_TEST_HELPERS -Force +. "$PSScriptRoot\script.ps1" + +Reset-TestResults + +# --- Get-RepoCloneJiraKey ----------------------------------------------------- + +$canonical = @" +# Jira Context: Some Initiative + +## Metadata + +| Field | Value | +|-------|-------| +| Jira Key | CP-94926 | +| Summary | Do the thing | +"@ +Assert-Equal -Name "jira-key: canonical row" -Expected "CP-94926" ` + -Actual (Get-RepoCloneJiraKey -Content $canonical) + +$variant = @" +# Jira Context: ENHANCE Programme + +| Field | Value | +|-------|-------| +| Primary Jira Keys | CP-94926, CP-94927, CP-94928 (stories in scope) | +| Parent Epic | CP-94925 -- ENHANCE-9851 | +"@ +Assert-Equal -Name "jira-key: Primary Jira Keys variant (first key)" -Expected "CP-94926" ` + -Actual (Get-RepoCloneJiraKey -Content $variant) + +$parentOnly = @" +| Field | Value | +|-------|-------| +| Parent Epic | CP-94925 -- desc | +"@ +Assert-Equal -Name "jira-key: Parent Epic variant" -Expected "CP-94925" ` + -Actual (Get-RepoCloneJiraKey -Content $parentOnly) + +$h1Only = "# Jira Context: ENHANCE-9851 -- big programme`n`nNo metadata table here." +Assert-Equal -Name "jira-key: H1 title fallback" -Expected "ENHANCE-9851" ` + -Actual (Get-RepoCloneJiraKey -Content $h1Only) + +Assert-True -Name "jira-key: null when no key present" ` + -Condition ($null -eq (Get-RepoCloneJiraKey -Content "# Title`n`nNothing key-shaped here.")) ` + -Message "Expected null for content with no key" + +Assert-True -Name "jira-key: null for empty content" ` + -Condition ($null -eq (Get-RepoCloneJiraKey -Content "")) ` + -Message "Expected null for empty content" + +# Case-sensitive: lower-case key-shaped tokens must NOT be treated as keys, +# otherwise tokens like `utf-8` / `sha-1` (or a stray lower-case key) would +# silently produce a wrong branch name. +Assert-True -Name "jira-key: lowercase key not matched (case-sensitive)" ` + -Condition ($null -eq (Get-RepoCloneJiraKey -Content "| Jira Key | cp-94926 |")) ` + -Message "Expected null for lowercase key" + +Assert-True -Name "jira-key: 'utf-8'-style token not matched" ` + -Condition ($null -eq (Get-RepoCloneJiraKey -Content "Encoded as utf-8 with sha-1 digest.")) ` + -Message "Expected null for non-key hyphenated tokens" + +# --- Test-RepoCloneComplete --------------------------------------------------- + +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-repo-clone-$([System.Guid]::NewGuid().ToString().Substring(0,8))" +New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + +try { + $missing = Join-Path $testRoot "missing" + Assert-True -Name "clone-complete: false for non-existent path" ` + -Condition (-not (Test-RepoCloneComplete -ClonePath $missing)) ` + -Message "Expected false" + + $emptyDir = Join-Path $testRoot "empty" + New-Item -Path $emptyDir -ItemType Directory -Force | Out-Null + Assert-True -Name "clone-complete: false for empty dir (no .git)" ` + -Condition (-not (Test-RepoCloneComplete -ClonePath $emptyDir)) ` + -Message "Expected false" + + $noRemote = Join-Path $testRoot "noremote" + New-Item -Path $noRemote -ItemType Directory -Force | Out-Null + & git -C $noRemote init --quiet 2>&1 | Out-Null + Assert-True -Name "clone-complete: false for git repo with no origin remote" ` + -Condition (-not (Test-RepoCloneComplete -ClonePath $noRemote)) ` + -Message "Expected false" + + # A valid clone of an empty (commitless) remote has a work tree + origin but + # no HEAD and no tracked files -- it must still count as complete so the tool + # does not wedge re-clone attempts on empty repos. + $emptyRemoteClone = Join-Path $testRoot "emptyremote" + New-Item -Path $emptyRemoteClone -ItemType Directory -Force | Out-Null + & git -C $emptyRemoteClone init --quiet 2>&1 | Out-Null + & git -C $emptyRemoteClone remote add origin "https://example.invalid/x/_git/y" 2>&1 | Out-Null + Assert-True -Name "clone-complete: true for empty repo with origin remote" ` + -Condition (Test-RepoCloneComplete -ClonePath $emptyRemoteClone) ` + -Message "Expected true (empty but valid clone)" + + $good = Join-Path $testRoot "good" + New-Item -Path $good -ItemType Directory -Force | Out-Null + & git -C $good init --quiet 2>&1 | Out-Null + & git -C $good remote add origin "https://example.invalid/x/_git/y" 2>&1 | Out-Null + & git -C $good config user.email "test@test.com" 2>&1 | Out-Null + & git -C $good config user.name "Test" 2>&1 | Out-Null + "readme" | Set-Content (Join-Path $good "README.md") + & git -C $good add -A 2>&1 | Out-Null + & git -C $good commit -m "init" --quiet 2>&1 | Out-Null + Assert-True -Name "clone-complete: true for populated repo" ` + -Condition (Test-RepoCloneComplete -ClonePath $good) ` + -Message "Expected true" +} finally { + if (Test-Path $testRoot) { + Remove-Item $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$allPassed = Write-TestSummary -LayerName "repo-clone" +if (-not $allPassed) { exit 1 } diff --git a/content/workflows/start-from-prompt/prompts/01-plan-product.md b/content/workflows/start-from-prompt/prompts/01-plan-product.md index 86ac0f57..f31cb043 100644 --- a/content/workflows/start-from-prompt/prompts/01-plan-product.md +++ b/content/workflows/start-from-prompt/prompts/01-plan-product.md @@ -229,16 +229,16 @@ the decision records created by Phase 1b.] Load dotbot MCP tools in a single ToolSearch call using the comma-separated `select:` form. Same pattern as `content/prompts/98-analyse-task.md`. ``` -ToolSearch({ query: "select:mcp__dotbot__task_set_status,mcp__dotbot__task_update,mcp__dotbot__decision_create,mcp__dotbot__decision_list" }) +ToolSearch({ query: "select:mcp__dotbot__task_get_context,mcp__dotbot__task_set_status,mcp__dotbot__task_update,mcp__dotbot__decision_create,mcp__dotbot__decision_list" }) ``` -If ToolSearch does not return all four selected `mcp__dotbot__*` tools after the documented warm-up retry, stop immediately and report that the dotbot MCP server is unavailable. Do not write `mission.md`, `tech-stack.md`, or `entity-model.md` without these tools; placeholder product docs are invalid output. +If ToolSearch does not return all five selected `mcp__dotbot__*` tools after the documented warm-up retry, stop immediately and report that the dotbot MCP server is unavailable. Do not write `mission.md`, `tech-stack.md`, or `entity-model.md` without these tools; placeholder product docs are invalid output. ### Phase 1: Read Source Material and Prior Answers 1. List `.bot/workspace/product/briefing/` and read every file. 2. Read `README.md` at the project root, `CLAUDE.md`, and any existing content in `docs/`. -3. Read `.bot/workspace/product/interview-answers.json` if it exists. This file holds answers from any prior clarification round on this task. Schema: `{ "answers": [{ "question_id", "question", "answer_key", "answer_label", "answer", "context", "answered_at" }, ...] }`. +3. Read this task's prior clarification answers from task state: call `mcp__dotbot__task_get_context({ task_id: "{{TASK_ID}}" })` and read `extensions.runner.questions_resolved` on the returned task record. This array holds every question already answered on this task (empty or absent on the first pass). Each entry: `{ "id", "question", "context", "answer_key", "answer_label", "answer", "answer_type", "answered_at" }`. (`id` is the question id.) 4. Call `mcp__dotbot__decision_list({ status: "accepted" })` to see accepted decisions already recorded for this project. These feed the Phase 4 dedupe and the Phase 5 `## Key Decisions` listing. ### Phase 2: Triage Ambiguities @@ -250,7 +250,7 @@ Build an internal list of every material ambiguity in the briefing — anything The bar for user-blocking: would a senior product owner want to be in the room for this call? If yes, ask. If no, decide. -Skip ambiguities already resolved in `interview-answers.json` from Phase 1. +Skip ambiguities already resolved in `questions_resolved` from Phase 1. **Hard cap of four.** The runtime supports exactly one clarification round per task. If the user-blocking bucket exceeds four items, rank by: @@ -289,7 +289,7 @@ mcp__dotbot__task_update({ mcp__dotbot__task_set_status({ task_id: "{{TASK_ID}}", status: "needs-input" }) ``` -Then STOP. The runner will pause the task, surface the questions to the user, and resume this prompt once every pending question has been answered. On resume, re-enter Phase 1 — `interview-answers.json` will contain the new answers. +Then STOP. The runner will pause the task, surface the questions to the user, and resume this prompt once every pending question has been answered. On resume, re-enter Phase 1 — `questions_resolved` will contain the new answers. Do not issue the pause pattern again on resume. The runtime sets `all_questions_answered = true` once the round closes and a second `task_set_status({ status: "needs-input" })` will throw. On resume, proceed straight from Phase 1 (re-read answers) to Phase 4 (record decisions) to Phase 5 (write deliverables). @@ -356,4 +356,4 @@ If, after Phase 3, no user-blocking question remained (everything was agent-deci - `tech-stack.md` covers the seven sections (Languages, Frameworks, Libraries, Tooling, Infrastructure, Dev Env, Rationale). Rationale references decision IDs where applicable. - `entity-model.md` includes at least one entity, all referenced enums, a Mermaid `erDiagram`, a Data Storage section, and a Design Decisions section that references decision IDs where applicable. - Every material ambiguity surfaced during planning has either a Decision record (status `accepted`) or is reflected in deliverable prose. Nothing is parked as an open question. -- For each user-answered question in `interview-answers.json`, a Decision exists with matching `title` and `tags` containing `clarification` and `stage:product-docs`. +- For each user-answered question in `questions_resolved`, a Decision exists with matching `title` and `tags` containing `clarification` and `stage:product-docs`. diff --git a/docs/DOTBOT-V4-FRAMEWORK.md b/docs/DOTBOT-V4-FRAMEWORK.md new file mode 100644 index 00000000..2c21a0b2 --- /dev/null +++ b/docs/DOTBOT-V4-FRAMEWORK.md @@ -0,0 +1,233 @@ +# dotbot v4 — Framework Overview + +> dotbot v4 is a PowerShell 7+ AI-assisted development orchestration framework. It coordinates Claude-powered task agents, manages worktrees, routes approvals, and integrates with your existing toolchain — all from a single git checkout. + +--- + +## Contents + +1. [What changed in v4](#what-changed-in-v4) +2. [Install](#install) +3. [Core concepts](#core-concepts) +4. [Key commands](#key-commands) +5. [Architecture](#architecture) +6. [Settings chain](#settings-chain) +7. [Workflow & stack model](#workflow--stack-model) +8. [Migration from v3](#migration-from-v3) + +--- + +## What changed in v4 + +v4 replaces the copy-based installer with a live git checkout model. The framework is no longer copied into your project — it lives in a single location (`DOTBOT_HOME`) and is resolved lazily at runtime. + +| | v3 | v4 | +|---|---|---| +| Install | `irm install-remote.ps1 \| iex` copies framework into `~/dotbot` | `git clone` + `pwsh bootstrap.ps1` drops a PATH shim | +| Framework location | Copied into `~/dotbot`, updated via `dotbot update` | One git checkout, `DOTBOT_HOME` points at it | +| Project `.bot/` | Contains copies of `src/`, `content/`, `settings/`, `hooks/` | Contains only `workspace/` and `.gitignore` | +| Settings source | `/settings/settings.default.json` | `/content/settings/settings.default.json` | +| Entry point | `.bot\go.ps1` / `.bot\init.ps1` | `dotbot go` (runtime + UI) / `dotbot serve` (runtime only) | +| PowerShell Gallery | `Install-Module Dotbot` | Retired | + +--- + +## Install + +### Requirements + +- PowerShell 7.2+ (PowerShell 5.1 is rejected at install time) +- Git + +### One-time setup + +```powershell +# Clone the framework +git clone https://github.com/andresharpe/dotbot ~/dotbot + +# Install the PATH shim and set DOTBOT_HOME +pwsh ~/dotbot/bootstrap.ps1 + +# Confirm +dotbot status +``` + +`bootstrap.ps1` drops a shim into: +- **Windows:** `%LOCALAPPDATA%\Microsoft\WindowsApps\dotbot.ps1` +- **macOS / Linux:** `~/.local/bin/dotbot` + +It never writes `DOTBOT_HOME` to the machine environment — you set it in your shell profile or project init. + +### Package managers + +```bash +# Homebrew (macOS / Linux) +brew install andresharpe/dotbot/dotbot + +# Scoop (Windows) +scoop bucket add dotbot https://github.com/andresharpe/scoop-dotbot +scoop install dotbot +``` + +--- + +## Core concepts + +### DOTBOT_HOME + +The environment variable that points at your dotbot framework checkout. All runtime modules, content, and settings are resolved from this path. Package-managed installs and bootstrapped shims set this automatically — you only need to set it explicitly if you maintain a manual clone or multiple framework versions side by side. + +```powershell +$env:DOTBOT_HOME = "~/dotbot" # point at your clone +``` + +### Project `.bot/` + +A minimal directory committed to your project repo. In v4 it contains only: + +``` +.bot/ + .gitignore + workspace/ # task files, answers, outputs + .control/ # runtime state (settings.json, instance_id) + content/ # project-tier overrides only (created on demand) +``` + +Framework content (workflows, stacks, MCP tools) is resolved lazily from `DOTBOT_HOME` — never copied into `.bot/`. + +### Workflows + +A workflow defines the end-to-end process for a class of work (e.g. `start-from-jira`, `start-from-prompt`). Workflows live in `/content/workflows/` and can be extended with `extends` in `workflow.json`. + +```json +{ + "name": "my-workflow", + "extends": "start-from-prompt" +} +``` + +### Stacks + +A stack adds technology-specific content (MCP tools, hooks, settings) layered on top of the framework defaults. Active stacks are declared in `.bot/.control/settings.json` and resolved via the `ContentResolver`. + +### Providers + +A provider configures the AI model backend (Claude, Codex, Copilot, Gemini). Provider settings live in `/content/settings/providers/`. + +--- + +## Key commands + +| Command | What it does | +|---|---| +| `dotbot status` | Shows resolved `DOTBOT_HOME`, framework branch/SHA/dirty flag, version, active workflow, provider, stacks | +| `dotbot status --json` | Same, machine-readable — used by CI scripts and the dashboard banner | +| `dotbot init` | Bootstraps a project `.bot/` directory (sparse — no framework copies) | +| `dotbot init -Workflow start-from-jira` | Init with a specific workflow materialised | +| `dotbot go` | Starts the task runner and Studio UI for the current project | +| `dotbot serve` | Starts the task runner only (no UI) — for headless / CI use | +| `dotbot workflow add ` | Activates a workflow in `.control/settings.json` | +| `dotbot workflow remove ` | Deactivates a workflow | + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Studio UI (src/studio-ui/) │ +│ Browser dashboard · task list · roadmap · approvals│ +└───────────────────┬─────────────────────────────────┘ + │ HTTP +┌───────────────────▼─────────────────────────────────┐ +│ Runtime (src/runtime/) │ +│ Task runner · worktree manager · MCP preflight │ +│ Inbox watcher · approval router · event emitter │ +└───────────────────┬─────────────────────────────────┘ + │ PowerShell modules +┌───────────────────▼─────────────────────────────────┐ +│ ContentResolver │ +│ Framework (DOTBOT_HOME) → Active stacks → Project │ +└───────────────────┬─────────────────────────────────┘ + │ +┌───────────────────▼─────────────────────────────────┐ +│ Claude (via MCP) │ +│ claude.exe · per-task worktree · MCP tool set │ +└─────────────────────────────────────────────────────┘ +``` + +### Content resolution order + +The `ContentResolver` folds content in priority order: + +1. **Project tier** — `.bot/content/` (project-specific overrides) +2. **Active stacks** — resolved via `extends` chain in `.control/settings.json` +3. **Framework** — `/content/` + +The first match wins. This means project overrides beat stack defaults, which beat framework defaults. + +--- + +## Settings chain + +Four layers merged in order (last wins): + +| Layer | Source | Purpose | +|---|---|---| +| 1 — Framework defaults | `/content/settings/settings.default.json` | Baseline for all projects | +| 2 — Project override | `.bot/content/settings/settings.default.json` | Project-specific defaults (tracked) | +| 3 — User settings | `~/.config/dotbot/user-settings.json` | Per-developer overrides (not tracked) | +| 4 — Control state | `.bot/.control/settings.json` | Runtime state: active workflow, stacks, instance_id | + +--- + +## Workflow & stack model + +### Adding a workflow + +```powershell +dotbot workflow add start-from-jira +``` + +This records the active workflow in `.control/settings.json`. The workflow's content is resolved lazily from `DOTBOT_HOME` — no files are copied unless the workflow ships an `overrides/` subtree. + +### Workflow inheritance + +```json +// .bot/content/workflows/my-workflow/workflow.json +{ + "name": "my-workflow", + "extends": "start-from-prompt", + "phases": [ + { + "name": "spec" + } + ] +} +``` + +### MCP tool discovery + +The runtime walks both `tools/` (v4 layout) and `systems/mcp/tools/` (legacy layout) under each workflow source, so existing v3-style registries continue to work. + +--- + +## Migration from v3 + +See [`MIGRATING.md`](../MIGRATING.md) at the repo root for the full step-by-step guide. The short version: + +1. Archive `~/dotbot` (your v3 copy) +2. Clone afresh: `git clone https://github.com/andresharpe/dotbot ~/dotbot` +3. Run `pwsh ~/dotbot/bootstrap.ps1` +4. Set `$env:DOTBOT_HOME = "~/dotbot"` in your shell profile +5. Per project: `git rm -r .bot/src .bot/content .bot/settings .bot/recipes .bot/hooks` +6. Remove `.bot/.manifest.json`, `.bot/go.ps1`, `.bot/init.ps1` +7. Run `dotbot init` to create the v4 `.bot/` structure +8. Run `dotbot status` to confirm + +The `~/dotbot/user-settings.json → ~/.config/dotbot/user-settings.json` move happens automatically on first run. + +--- + +*Last updated: 2026-07-07* +*See also: [MIGRATING.md](../MIGRATING.md) · [AGENTS.md](../AGENTS.md) · [Release Notes](release-notes/v4.0.1.md)* diff --git a/docs/ROADMAP-RELEASES.md b/docs/ROADMAP-RELEASES.md new file mode 100644 index 00000000..135c1011 --- /dev/null +++ b/docs/ROADMAP-RELEASES.md @@ -0,0 +1,252 @@ +# dotbot v4 — Release Roadmap + +> Three focused releases. Each one unlocks the next. + +--- + +## Delivery History + +| Version | Released | Issues Closed | +|---|---|---| +| v4.0.0 | June 4, 2026 | Initial v4 launch | +| [v4.0.1](release-notes/v4.0.1.md) | July 3, 2026 | 41 issues — 27 fixes, 14 enhancements | + +--- + +## Release Summary + +| Release | Version | Theme | Focus | Issues | +|---|---|---|---|---| +| R1 | v4.1 | **Stable · Enterprise · Drone** | Harden the runtime, deliver questionnaire & approval layer, land fleet registration & Drone | 13 | +| R2 | v4.2 | **Fleet · Workflow · Nice-to-have** | Fleet dashboard & auth, complete the workflow engine, ship deferred enterprise & improvement items | 15 | +| R3 | v4.3 | **Intelligence · Improvements** | AI self-improvement, knowledge injection, Aether conduits, remaining improvement backlog | 9 | +| — | ongoing | **UI / UX** | Design system delivered incrementally across all releases | 5 | + +--- + +## R1 — v4.1 · Stable · Enterprise · Drone + +> Harden the runtime. Deliver questionnaire & approval layer. Land fleet registration & Drone. + +**Why first:** Stabilization items unblock clean development. The Event Bus (#93) is a hard blocker for fleet, drone, and enterprise features. Enterprise questionnaire and team/roles form the approval layer that downstream automation depends on. + +### Stabilization (2 remaining) + +> 8 of 10 stabilization items shipped in v4.0.1. + +| Issue | Title | +|---|---| +| [#509](https://github.com/andresharpe/dotbot/issues/509) | Preflight content-aware checks | +| [#458](https://github.com/andresharpe/dotbot/issues/458) | Update README docs | + +
+Closed in v4.0.1 (8 issues) + +| Issue | Title | +|---|---| +| ~~[#537](https://github.com/andresharpe/dotbot/issues/537)~~ | ~~Orphan worktree corrupts filesystem~~ | +| ~~[#536](https://github.com/andresharpe/dotbot/issues/536)~~ | ~~Non-atomic task claim under concurrency~~ | +| ~~[#518](https://github.com/andresharpe/dotbot/issues/518)~~ | ~~Output delta validation on resume~~ | +| ~~[#516](https://github.com/andresharpe/dotbot/issues/516)~~ | ~~interview-answers.json merge conflicts~~ | +| ~~[#519](https://github.com/andresharpe/dotbot/issues/519)~~ | ~~Products page missing artifacts~~ | +| ~~[#393](https://github.com/andresharpe/dotbot/issues/393)~~ | ~~CI Bump & Release fails~~ | +| ~~[#504](https://github.com/andresharpe/dotbot/issues/504)~~ | ~~Cannot find Claude and Git~~ | +| ~~[#511](https://github.com/andresharpe/dotbot/issues/511)~~ | ~~Manage dotbot versioning~~ | + +
+ +### Enterprise (3 remaining) + +> 2 of 5 enterprise items shipped in v4.0.1. + +| Issue | Title | +|---|---| +| [#93](https://github.com/andresharpe/dotbot/issues/93) | Event bus — critical blocker | +| [#545](https://github.com/andresharpe/dotbot/issues/545) | Q&A web provider & channel prefs | +| [#98](https://github.com/andresharpe/dotbot/issues/98) | Project team & roles | + +
+Closed in v4.0.1 (2 issues) + +| Issue | Title | +|---|---| +| ~~[#29](https://github.com/andresharpe/dotbot/issues/29)~~ | ~~Expand QuestionService & approvals~~ | +| ~~[#30](https://github.com/andresharpe/dotbot/issues/30)~~ | ~~Jira as an approval channel~~ | + +
+ +### Mothership & Fleet (6 issues) + +| Issue | Title | +|---|---| +| [#544](https://github.com/andresharpe/dotbot/issues/544) | Fleet server: registration & heartbeat | +| [#95](https://github.com/andresharpe/dotbot/issues/95) | Mothership fleet coordination | +| [#96](https://github.com/andresharpe/dotbot/issues/96) | Drone agent — remote task execution | +| [#576](https://github.com/andresharpe/dotbot/issues/576) | Mothership deployment & setup (Dockerfile, docker-compose, env reference) | +| [#574](https://github.com/andresharpe/dotbot/issues/574) | Drone credential store (PAT-in-URL fix, http.extraHeader auth) | +| [#575](https://github.com/andresharpe/dotbot/issues/575) | Outpost-to-Drone task delegation (execution: local\|drone\|auto) | + +**Gate to R2:** Event Bus emitting events end-to-end · all stabilization items closed · fleet registration working · Drone agent spawning + +--- + +## R2 — v4.2 · Fleet · Workflow · Nice-to-have + +> Fleet dashboard & auth. Complete the workflow engine. Ship deferred enterprise & improvement items. + +**Why second:** Fleet dashboard and OIDC/RBAC build on the fleet registration layer from R1. Workflow Builder completion depends on the process refactor and event-driven triggers landed in R1. Enterprise and Mothership nice-to-haves are deferred here to keep R1 lean. + +### Enterprise (2 issues) + +| Issue | Title | +|---|---| +| [#38](https://github.com/andresharpe/dotbot/issues/38) | OpenClaw channels for orchestration | +| [#39](https://github.com/andresharpe/dotbot/issues/39) | Jira-initiated project launch | + +### Mothership & Fleet (2 issues) + +| Issue | Title | +|---|---| +| [#547](https://github.com/andresharpe/dotbot/issues/547) | Fleet dashboard: instance cards & metrics | +| [#548](https://github.com/andresharpe/dotbot/issues/548) | Auth layer: OIDC, IAuthProvider & RBAC | + +### Workflow Builder (5 issues) + +> `#522` removed — delivered as [#427](https://github.com/andresharpe/dotbot/issues/427) in v4.0.1. + +| Issue | Title | +|---|---| +| [#129](https://github.com/andresharpe/dotbot/issues/129) | GitHub Workflow Family | +| [#102](https://github.com/andresharpe/dotbot/issues/102) | User-level workflow editor | +| [#380](https://github.com/andresharpe/dotbot/issues/380) | Skill Builder Feature | +| [#542](https://github.com/andresharpe/dotbot/issues/542) | Process isolation: InterviewLoop & IPolicyEvaluator | +| [#543](https://github.com/andresharpe/dotbot/issues/543) | HealthAPI, ConfigValidator & idempotent init | + +### Improvements & Dev Exp (6 issues) + +| Issue | Title | +|---|---| +| [#512](https://github.com/andresharpe/dotbot/issues/512) | Selective Workflow Re-run | +| [#510](https://github.com/andresharpe/dotbot/issues/510) | Task Output Contract & execution gates | +| [#503](https://github.com/andresharpe/dotbot/issues/503) | On-Demand External Task Trigger | +| [#416](https://github.com/andresharpe/dotbot/issues/416) | Promote inbound decisions to ADRs | +| [#546](https://github.com/andresharpe/dotbot/issues/546) | Telemetry: OTel SDK & sinks | +| [#550](https://github.com/andresharpe/dotbot/issues/550) | Registry: remove, namespace:stack & auto-update | + +**Gate to R3:** Fleet dashboard live · OIDC/RBAC enforced · Workflow Builder feature-complete · all improvement items closed + +--- + +## R3 — v4.3 · Intelligence · Improvements + +> AI self-improvement, knowledge injection, Aether conduits, and the remaining improvement backlog. + +**Why third:** Self-improvement loop and Aether conduits depend on a stable Event Bus (R1) and a working fleet/drone layer (R2). Knowledge provider and shared memory require the workflow engine to be complete (R2). + +### Improvements & Dev Exp (5 issues) + +| Issue | Title | +|---|---| +| [#97](https://github.com/andresharpe/dotbot/issues/97) | Self-improvement loop | +| [#99](https://github.com/andresharpe/dotbot/issues/99) | Aether conduit plugin architecture | +| [#549](https://github.com/andresharpe/dotbot/issues/549) | IKnowledgeProvider: vector DB & ontology | +| [#76](https://github.com/andresharpe/dotbot/issues/76) | Built-in Shared Memory System | +| [#505](https://github.com/andresharpe/dotbot/issues/505) | Filter & Sort Decisions per Workflow | + +### Workflow Builder (4 issues) + +Remaining Workflow Builder items to be scheduled into R3 as R2 closes. Candidates from the gap analysis: +- `workflow-status`, `pause` & `resume` MCP tools (Ph3/Ph7 tail) +- Workflow tab UI & task lifecycle visualisation (Ph3 tail) +- Mothership registry sync & auto-update trigger (Ph11 tail) +- Additional workflow policy and retry items identified during R2 + +**Gate to ship:** Self-improvement loop running · at least 2 Aether conduit types bonding to events · Workflow Builder fully closed + +--- + +## UI / UX — Ongoing Across All Releases + +Design system delivered incrementally. Not gated to a single release. + +| Issue | Title | +|---|---| +| [#32](https://github.com/andresharpe/dotbot/issues/32) | Workflow tab UI & task lifecycle viz | +| [#551](https://github.com/andresharpe/dotbot/issues/551) | Navigation shell redesign | +| [#552](https://github.com/andresharpe/dotbot/issues/552) | Visual design system | +| [#553](https://github.com/andresharpe/dotbot/issues/553) | ⌘K command palette | +| [#554](https://github.com/andresharpe/dotbot/issues/554) | Accessibility & ADR integration | + +--- + +## Dependency Chain + +``` +R1: Stabilization ──► Event Bus (#93) ──────────────────────────┐ + │ │ + ├─► Enterprise Q&A (#545, #98) │ + ├─► Fleet registration (#544, #95) │ + ├─► Drone agent (#96) │ + ├─► Mothership setup (#576) │ + ├─► Drone credential store (#574) │ + └─► Task delegation (#575) │ + │ +R2: Fleet dashboard (#547, #548) ◄── R1 fleet layer │ + Workflow Builder (#129, #102, #380, #542, #543) │ + Improvements (#512, #510, #503, #416, #546, #550) │ + Enterprise nice-to-have (#38, #39) │ + │ +R3: Self-improvement (#97) ◄── drones (R1) + workflow (R2) │ + Aether conduits (#99) ◄──────────────────────────────────────┘ + Knowledge provider (#549), Shared memory (#76) + Remaining Workflow Builder (4 items) + +UI/UX (#32, #551, #552, #553, #554) ── ongoing across R1 → R3 +``` + +--- + +## All Issues by Release + +| Issue | Title | Release | Area | +|---|---|---|---| +| [#509](https://github.com/andresharpe/dotbot/issues/509) | Preflight content-aware checks | R1 | V4-Stabilization | +| [#458](https://github.com/andresharpe/dotbot/issues/458) | Update README docs | R1 | V4-Stabilization | +| [#93](https://github.com/andresharpe/dotbot/issues/93) | Event bus — critical blocker | R1 | Enterprise Features | +| [#545](https://github.com/andresharpe/dotbot/issues/545) | Q&A web provider & channel prefs | R1 | Enterprise Features | +| [#98](https://github.com/andresharpe/dotbot/issues/98) | Project team & roles | R1 | Enterprise Features | +| [#544](https://github.com/andresharpe/dotbot/issues/544) | Fleet server: registration & heartbeat | R1 | Mothership & Fleet | +| [#95](https://github.com/andresharpe/dotbot/issues/95) | Mothership fleet coordination | R1 | Mothership & Fleet | +| [#96](https://github.com/andresharpe/dotbot/issues/96) | Drone agent — remote task execution | R1 | Mothership & Fleet | +| [#576](https://github.com/andresharpe/dotbot/issues/576) | Mothership deployment & setup | R1 | Mothership & Fleet | +| [#574](https://github.com/andresharpe/dotbot/issues/574) | Drone credential store | R1 | Mothership & Fleet | +| [#575](https://github.com/andresharpe/dotbot/issues/575) | Outpost-to-Drone task delegation | R1 | Mothership & Fleet | +| [#38](https://github.com/andresharpe/dotbot/issues/38) | OpenClaw channels for orchestration | R2 | Enterprise Features | +| [#39](https://github.com/andresharpe/dotbot/issues/39) | Jira-initiated project launch | R2 | Enterprise Features | +| [#547](https://github.com/andresharpe/dotbot/issues/547) | Fleet dashboard: instance cards & metrics | R2 | Mothership & Fleet | +| [#548](https://github.com/andresharpe/dotbot/issues/548) | Auth layer: OIDC, IAuthProvider & RBAC | R2 | Mothership & Fleet | +| [#129](https://github.com/andresharpe/dotbot/issues/129) | GitHub Workflow Family | R2 | Workflow Builder | +| [#102](https://github.com/andresharpe/dotbot/issues/102) | User-level workflow editor | R2 | Workflow Builder | +| [#380](https://github.com/andresharpe/dotbot/issues/380) | Skill Builder Feature | R2 | Workflow Builder | +| [#542](https://github.com/andresharpe/dotbot/issues/542) | Process isolation: InterviewLoop & IPolicyEvaluator | R2 | Workflow Builder | +| [#543](https://github.com/andresharpe/dotbot/issues/543) | HealthAPI, ConfigValidator & idempotent init | R2 | Workflow Builder | +| [#512](https://github.com/andresharpe/dotbot/issues/512) | Selective Workflow Re-run | R2 | Improvements & Developer Experience | +| [#510](https://github.com/andresharpe/dotbot/issues/510) | Task Output Contract & execution gates | R2 | Improvements & Developer Experience | +| [#503](https://github.com/andresharpe/dotbot/issues/503) | On-Demand External Task Trigger | R2 | Improvements & Developer Experience | +| [#416](https://github.com/andresharpe/dotbot/issues/416) | Promote inbound decisions to ADRs | R2 | Improvements & Developer Experience | +| [#546](https://github.com/andresharpe/dotbot/issues/546) | Telemetry: OTel SDK & sinks | R2 | Improvements & Developer Experience | +| [#550](https://github.com/andresharpe/dotbot/issues/550) | Registry: remove, namespace:stack & auto-update | R2 | Improvements & Developer Experience | +| [#97](https://github.com/andresharpe/dotbot/issues/97) | Self-improvement loop | R3 | Improvements & Developer Experience | +| [#99](https://github.com/andresharpe/dotbot/issues/99) | Aether conduit plugin architecture | R3 | Improvements & Developer Experience | +| [#549](https://github.com/andresharpe/dotbot/issues/549) | IKnowledgeProvider: vector DB & ontology | R3 | Improvements & Developer Experience | +| [#76](https://github.com/andresharpe/dotbot/issues/76) | Built-in Shared Memory System | R3 | Improvements & Developer Experience | +| [#505](https://github.com/andresharpe/dotbot/issues/505) | Filter & Sort Decisions per Workflow | R3 | Improvements & Developer Experience | +| [#32](https://github.com/andresharpe/dotbot/issues/32) | Workflow tab UI & task lifecycle viz | ongoing | UI/UX | +| [#551](https://github.com/andresharpe/dotbot/issues/551) | Navigation shell redesign | ongoing | UI/UX | +| [#552](https://github.com/andresharpe/dotbot/issues/552) | Visual design system | ongoing | UI/UX | +| [#553](https://github.com/andresharpe/dotbot/issues/553) | ⌘K command palette | ongoing | UI/UX | +| [#554](https://github.com/andresharpe/dotbot/issues/554) | Accessibility & ADR integration | ongoing | UI/UX | + +--- + +*Last updated: 2026-07-03* diff --git a/docs/release-notes/v4.0.1.md b/docs/release-notes/v4.0.1.md new file mode 100644 index 00000000..254adbd6 --- /dev/null +++ b/docs/release-notes/v4.0.1.md @@ -0,0 +1,159 @@ +# dotbot v4.0.1 — Release Notes + +**Released:** July 3, 2026 +**Previous version:** v4.0.0 (June 4, 2026) +**Type:** Patch release + +> 27 bugs fixed · 14 enhancements · 41 issues closed + +--- + +## Highlights + +**Worktree concurrency hardened** — [#536](https://github.com/andresharpe/dotbot/issues/536) [#537](https://github.com/andresharpe/dotbot/issues/537) [#520](https://github.com/andresharpe/dotbot/issues/520) [#516](https://github.com/andresharpe/dotbot/issues/516) [#517](https://github.com/andresharpe/dotbot/issues/517) [#514](https://github.com/andresharpe/dotbot/issues/514) [#515](https://github.com/andresharpe/dotbot/issues/515) +Parallel workloads are now safe. Duplicate worktree creation, mass-deletion of active worktrees, and filesystem corruption on orphan cleanup are all resolved. + +**Task resume is reliable** — [#470](https://github.com/andresharpe/dotbot/issues/470) [#493](https://github.com/andresharpe/dotbot/issues/493) [#494](https://github.com/andresharpe/dotbot/issues/494) [#518](https://github.com/andresharpe/dotbot/issues/518) +Runner no longer exits silently on resume. Handoff manifests survive reopen. Output delta validation no longer fails on re-approval. + +**Question & Response API live** — [#451](https://github.com/andresharpe/dotbot/issues/451) [#445](https://github.com/andresharpe/dotbot/issues/445) [#29](https://github.com/andresharpe/dotbot/issues/29) +Mothership and outpost now share a structured Q&A envelope contract. Approvals, document review, and roles unified under one API surface. + +**"Revise" — give feedback without losing your work** — [#465](https://github.com/andresharpe/dotbot/issues/465) +New Revise button on needs-review tasks lets reviewers inject feedback and trigger partial regeneration in place. Worktree preserved — no full restart, no lost context. + +**Workflow inheritance** — [#427](https://github.com/andresharpe/dotbot/issues/427) +`extends:` in `workflow.yaml` lets workflows inherit from a base definition, at parity with stack inheritance. + +**New install model** +`bootstrap.ps1` replaces the old copy-based installer. One `git clone` + `pwsh bootstrap.ps1` is the full setup. `dotbot status` surfaces framework state at any time. + +**Needs-review tasks visible in the pipeline** — [#500](https://github.com/andresharpe/dotbot/issues/500) +Tasks waiting for review now appear in the Needs Input pipeline section — no more hunting for pending work. + +--- + +## Stabilization — 27 bugs fixed + +### Worktree & Concurrency + +| Issue | Fix | +|---|---| +| [#536](https://github.com/andresharpe/dotbot/issues/536) | Non-atomic task claim was creating duplicate worktrees under concurrent runs | +| [#537](https://github.com/andresharpe/dotbot/issues/537) | Orphan worktree partial setup was corrupting the filesystem on retry | +| [#520](https://github.com/andresharpe/dotbot/issues/520) | `Remove-OrphanWorktrees` mass-deleted all active worktrees due to task schema mismatch post-v4 | +| [#516](https://github.com/andresharpe/dotbot/issues/516) | `interview-answers.json` caused squash-merge conflicts when parallel tasks wrote simultaneously | +| [#517](https://github.com/andresharpe/dotbot/issues/517) | Untracked task files blocked squash-merge retry after first attempt failed | +| [#514](https://github.com/andresharpe/dotbot/issues/514) | `Test-DotbotMcpReadiness` crashed on task retry due to stale worktree junction | +| [#515](https://github.com/andresharpe/dotbot/issues/515) | Same stale junction crash — separate code path, both now resolved | + +### Task Lifecycle & Runner + +| Issue | Fix | +|---|---| +| [#470](https://github.com/andresharpe/dotbot/issues/470) | Task stuck in-progress indefinitely when runner process was killed mid-execution | +| [#493](https://github.com/andresharpe/dotbot/issues/493) | Runner exited silently on resume when a needs-input task blocked the queue | +| [#494](https://github.com/andresharpe/dotbot/issues/494) | Handoff manifest deleted on runner reopen — answer submission failed with "manifest not found" | +| [#518](https://github.com/andresharpe/dotbot/issues/518) | Output delta validation failed on resume-after-approval when artifact already existed | + +### Dashboard & UI + +| Issue | Fix | +|---|---| +| [#471](https://github.com/andresharpe/dotbot/issues/471) | STOP button gave no feedback — tooltip and notification now wired; hard kill available via Processes tab | +| [#519](https://github.com/andresharpe/dotbot/issues/519) | Products page blank for reviewers — task artifacts were hidden before approval | +| [#453](https://github.com/andresharpe/dotbot/issues/453) | Workflow task showing wrong phase during execution | +| [#454](https://github.com/andresharpe/dotbot/issues/454) | Roadmap "Done" section not showing all completed tasks | +| [#463](https://github.com/andresharpe/dotbot/issues/463) | Roadmap page dropdown missing workflows | +| [#477](https://github.com/andresharpe/dotbot/issues/477) | `escapeHtml` misused in HTML attribute context — XSS surface in actions.js resolved | + +### Auth & Notifications + +| Issue | Fix | +|---|---| +| [#467](https://github.com/andresharpe/dotbot/issues/467) | Auth expiry was dead code — tasks never re-authenticated after token expiry; `AuthError` now wired to needs-input re-auth | +| [#468](https://github.com/andresharpe/dotbot/issues/468) | Entering `needs-review` emitted no notification — reviewers were never alerted | + +### CI & Release Pipeline + +| Issue | Fix | +|---|---| +| [#460](https://github.com/andresharpe/dotbot/issues/460) | CI broken post-v4 restructure — .yml extensions stripped from `.github/workflows/` | +| [#475](https://github.com/andresharpe/dotbot/issues/475) | Bump-release workflow failing — `dotbot.psd1` missing after installer removal | +| [#487](https://github.com/andresharpe/dotbot/issues/487) | Discord release notification silently skipped for fork PRs | +| [#393](https://github.com/andresharpe/dotbot/issues/393) | Bump-and-release blocked by protected branch preventing direct push to main | +| [#476](https://github.com/andresharpe/dotbot/issues/476) | Layer 2 integration test was launching a real Claude process instead of a mock | +| [#474](https://github.com/andresharpe/dotbot/issues/474) | macOS CI Layer 1–3 tests had timing flakes | +| [#484](https://github.com/andresharpe/dotbot/issues/484) | Flaky Layer 5 UI E2E tests — Playwright retries now enabled | +| [#482](https://github.com/andresharpe/dotbot/issues/482) | Issue template chooser broken by JSON refactor | + +--- + +## Enhancements — 14 items + +### Install & Operations + +| | Enhancement | +|---|---| +| NEW | **`bootstrap.ps1`** — one-step install replacing the old copy-based installer; works on Windows, macOS, and Linux | +| NEW | **`dotbot status`** — surfaces resolved `DOTBOT_HOME`, framework branch/SHA, active workflow, provider and stacks; `--json` for CI scripts | +| NEW | **`MIGRATING.md`** — v3 → v4 migration guide covering shim install, `.bot/` rewrite, `.mcp.json` repointing, and retired entry points | + +### Task Execution + +| Issue | Enhancement | +|---|---| +| [#465](https://github.com/andresharpe/dotbot/issues/465) | **"Revise" review verb** — reviewer feedback injected in place; only affected section regenerates, worktree preserved | +| [#466](https://github.com/andresharpe/dotbot/issues/466) | Per-run integration branch — configurable base branch, provider-agnostic | +| [#521](https://github.com/andresharpe/dotbot/issues/521) | `MCP_TIMEOUT` / `MCP_TOOL_TIMEOUT` injected per-task so `claude.exe` survives slow MCP cold-starts | + +### Workflow Engine + +| Issue | Enhancement | +|---|---| +| [#427](https://github.com/andresharpe/dotbot/issues/427) | **`extends:` in `workflow.yaml`** — workflow inheritance from a base definition, at parity with stack inheritance | + +### Approvals & Q&A + +| Issue | Enhancement | +|---|---| +| [#451](https://github.com/andresharpe/dotbot/issues/451) | **Question & Response API** — structured Q&A envelope contract on mothership and outpost | +| [#445](https://github.com/andresharpe/dotbot/issues/445) | Merge approval and document review consolidated into one unified approval type | +| [#500](https://github.com/andresharpe/dotbot/issues/500) | Needs-review tasks now surfaced in the Needs Input pipeline section | +| [#29](https://github.com/andresharpe/dotbot/issues/29) | Expanded QuestionService — artifact approvals, roles, and new question types | + +### Dashboard & Integrations + +| Issue | Enhancement | +|---|---| +| [#469](https://github.com/andresharpe/dotbot/issues/469) | Resizable table columns and draggable panel separators — layout persisted across sessions | +| [#435](https://github.com/andresharpe/dotbot/issues/435) | Azurite blob storage emulator support for local mothership server testing | + +### AP Registry & Workflows + +| Issue | Enhancement | +|---|---| +| [#506](https://github.com/andresharpe/dotbot/issues/506) | QA-specific workflow definitions added to registry | +| [#507](https://github.com/andresharpe/dotbot/issues/507) / [#508](https://github.com/andresharpe/dotbot/issues/508) | AP Registry v4 compatibility check + classified registry restructured (default / client / role) | + +--- + +## Get this release + +```bash +# Homebrew (macOS / Linux) +brew upgrade dotbot + +# Scoop (Windows) +scoop update dotbot + +# From source +git pull && pwsh bootstrap.ps1 +``` + +Run `dotbot status` after updating to confirm you're on v4.0.1. + +--- + +*Next patch release: ~July 10, 2026* +*Release notes template: [`docs/release-notes/`](../release-notes/)* diff --git a/docs/release-notes/v4.0.2.md b/docs/release-notes/v4.0.2.md new file mode 100644 index 00000000..6c5fb9c6 --- /dev/null +++ b/docs/release-notes/v4.0.2.md @@ -0,0 +1,82 @@ +# dotbot v4.0.2 — Release Notes + +**Released:** July 10, 2026 +**Previous version:** v4.0.1 (July 3, 2026) +**Type:** Patch release + +> 6 bugs fixed · 2 enhancements · 1 CI fix · 13 issues closed + +--- + +## Highlights + +**Post-task pipeline and repo-clone hardened** — [#566](https://github.com/andresharpe/dotbot/issues/566) [#568](https://github.com/andresharpe/dotbot/issues/568) [#569](https://github.com/andresharpe/dotbot/issues/569) [#570](https://github.com/andresharpe/dotbot/issues/570) [#571](https://github.com/andresharpe/dotbot/issues/571) +Five of the eight bugs tracked in the [#557](https://github.com/andresharpe/dotbot/issues/557) omnibus are resolved. ADO PAT no longer leaks in clone URLs. Recipe links and output paths resolve correctly. Condition-skip cascades and parallel barriers behave as designed. Orphan worktrees with unborn master no longer break squash-merge. Privacy scan no longer mislabels successful tasks as skipped. + +**Priority sort fixed** — [#613](https://github.com/andresharpe/dotbot/issues/613) [#614](https://github.com/andresharpe/dotbot/issues/614) +`Get-NextWorkflowTask` was sorting eligible tasks in reverse priority order — higher priority numbers were winning instead of lower. Tasks now execute in the intended order. + +**Registry remove command** — [#550](https://github.com/andresharpe/dotbot/issues/550) +`dotbot registry remove ` removes a registered stack or workflow. RegistryManager now auto-updates the registry on `init` and `run`, keeping registrations in sync without a manual step. + +**Linked issues auto-close on release branch merge** — [#617](https://github.com/andresharpe/dotbot/issues/617) +CI now processes `Closes #NNN` keywords when a PR merges into a `release/*` or `releases/*` branch and closes the linked issues automatically. Previously this only worked for PRs targeting `main`. + +--- + +## Stabilization — 6 bugs fixed + +### Post-task pipeline & repo-clone + +| Issue | PR | Fix | +|---|---|---| +| [#566](https://github.com/andresharpe/dotbot/issues/566) | [#565](https://github.com/andresharpe/dotbot/pull/565) | ADO PAT leaked into the clone URL logged to the activity log; Jira-key parser rejected all clone paths that included a period in the project key | +| [#568](https://github.com/andresharpe/dotbot/issues/568) | [#572](https://github.com/andresharpe/dotbot/pull/572) | `Invoke-WorkflowProcess` recipe resolver produced a broken link; output path join double-nested the outputs directory | +| [#569](https://github.com/andresharpe/dotbot/issues/569) | [#573](https://github.com/andresharpe/dotbot/pull/573) | Condition-skip on a dep-gated step propagated a false skip through all downstream steps; parallel barriers did not wait for all spawned children before advancing | +| [#570](https://github.com/andresharpe/dotbot/issues/570) | [#578](https://github.com/andresharpe/dotbot/pull/578) | Orphan worktrees cloned from repos with no commits (`unborn master`) failed squash-merge with an unrelated-history error and were not cleaned up | +| [#571](https://github.com/andresharpe/dotbot/issues/571) | [#579](https://github.com/andresharpe/dotbot/pull/579) | Privacy scan hook returned `done` when it should have escalated to `needs-input`; the verify-hook-blocked path now correctly escalates | + +### Task scheduling + +| Issue | PR | Fix | +|---|---|---| +| [#613](https://github.com/andresharpe/dotbot/issues/613) / [#614](https://github.com/andresharpe/dotbot/issues/614) | [#616](https://github.com/andresharpe/dotbot/pull/616) | `Get-NextWorkflowTask` negated the priority integer (`-[int]$priority`) before sorting ascending — inverting the order so higher numbers won. Negation removed; priority-less tasks now sort last via `[int]::MaxValue` sentinel | + +--- + +## Enhancements — 2 items + +| Issue | PR | Enhancement | +|---|---|---| +| [#550](https://github.com/andresharpe/dotbot/issues/550) | [#564](https://github.com/andresharpe/dotbot/pull/564) | **Registry remove command** — `dotbot registry remove ` removes a stack or workflow registration. New `RegistryManager` module; namespace:stack resolution; auto-update on `init` and `run` | +| [#617](https://github.com/andresharpe/dotbot/issues/617) | [#621](https://github.com/andresharpe/dotbot/pull/621) | **CI auto-close on release branch** — `Closes #NNN` keywords in PR bodies now trigger issue close when the PR merges into any `release/*` or `releases/*` branch, not only `main` | + +--- + +## CI & release pipeline fix + +| PR | Fix | +|---|---| +| [#567](https://github.com/andresharpe/dotbot/pull/567) | Bump-release workflow was failing after the v4 restructure. Switched to a PR-based flow; repaired the `dotbot.psd1` path; confirmed end-to-end on v4.0.1 | + +--- + +## Get this release + +```bash +# Homebrew (macOS / Linux) +brew upgrade dotbot + +# Scoop (Windows) +scoop update dotbot + +# From source +git pull && pwsh bootstrap.ps1 +``` + +Run `dotbot status` after updating to confirm you're on v4.0.2. + +--- + +*Next patch release: ~July 17, 2026* +*Release notes: [`docs/release-notes/`](../release-notes/)* diff --git a/src/README.md b/src/README.md index a5ebb4c2..c04da2fb 100644 --- a/src/README.md +++ b/src/README.md @@ -1,6 +1,6 @@ # dotbot - Autonomous Development Framework -A project-agnostic framework for autonomous software development across Claude Code, Codex, and Antigravity CLIs. Provides task management, two-phase execution (analysis + implementation), per-task git worktree isolation, a web dashboard, and a PowerShell MCP server. +A project-agnostic framework for autonomous software development across Claude Code, Codex, and Antigravity CLIs. Provides task management, single-session task execution, per-task git worktree isolation, a web dashboard, and a PowerShell MCP server. ## Installation @@ -45,7 +45,7 @@ dotbot go ├── recipes/ │ ├── agents/ # Agent personas (implementer, planner, reviewer, tester) │ ├── skills/ # Technical guidance (status, verify, write-test-plan, write-unit-tests) -│ ├── prompts/ # Numbered step-by-step processes (analysis, implementation, etc.) +│ ├── prompts/ # Numbered workflow and task prompt templates │ ├── includes/ # Shared prompt fragments │ └── research/ # Research templates ├── systems/ @@ -59,42 +59,29 @@ dotbot go └── workspace/ ├── product/ # Product docs (mission.md, entity-model.md, tech-stack.md) ├── decisions/ # Architecture decision records - └── tasks/ # Task queue (todo/, analysed/, in-progress/, done/) + └── tasks/ + ├── workflow-runs/ # Per-run directories with run.json + task JSON files + └── standalone/ # Standalone task JSON files ``` -## How It Works - -### Two-Phase Task Execution +Task status is stored in each task JSON file; task files do not move between per-status directories. -Every task goes through two phases, each run by a separate provider CLI instance: +## How It Works -**Phase 1 - Analysis** (`98-analyse-task.md`): -- Identifies affected entities and files -- Maps applicable coding standards -- Validates dependencies -- Produces concrete implementation guidance (insertion points, pattern snippets, field mappings) -- Asks clarifying questions or proposes task splits if needed +### Single-Session Task Execution -**Phase 2 - Execution** (`99-autonomous-task.md`): -- Receives pre-packaged analysis context (no re-exploration needed) -- Implements changes following the analysis guidance -- Runs verification scripts (privacy scan, git clean, build, format) -- Commits with task ID references +Prompt tasks run in one provider CLI session using `content/prompts/100-single-session-task.md`. That session does discovery, planning, implementation, verification, and completion together, so there is no separate pre-flight analysis process or intermediate handoff state. If the provider needs a human answer, it can pause the task as `needs-input`; after an answer or retry, the task returns to `todo` for another same-task session attempt. ### Per-Task Git Worktrees Each task runs in an isolated git worktree: ``` -Analysis picks up task +Task runner picks up task → Creates branch: task/{short-id}-{slug} → Creates worktree: ../worktrees/{repo}/task-{short-id}-{slug}/ - → Sets up junctions for shared .bot/ directories - → Copies essential gitignored files (e.g., .env) - -Execution picks up analysed task - → Looks up existing worktree - → Provider CLI implements and commits to task branch + → Materialises provider folders and MCP configuration in the worktree + → Provider CLI discovers, implements, verifies, and commits to task branch On completion → Rebases task branch onto main @@ -107,10 +94,14 @@ This provides full isolation between tasks — a failed task never leaves dirty ### Task Lifecycle ``` -todo → analysing → analysed → in-progress → done - │ │ - ├→ needs-input (question/split) └→ squash-merged to main - └→ skipped (non-recoverable) +todo → in-progress → done + │ │ │ + │ ├→ needs-input (human answer required, then retry from todo) + │ ├→ needs-review (completed work awaiting human review) + │ ├→ failed + │ ├→ skipped + │ └→ cancelled + └→ skipped / cancelled ``` ### Process Launcher @@ -119,8 +110,7 @@ todo → analysing → analysed → in-progress → done | Type | Purpose | |------|---------| -| `analysis` | Pre-flight task analysis | -| `execution` | Task implementation | +| `task-runner` | Workflow task execution loop | | `planning` | Roadmap generation | | `commit` | Git operations | | `task-creation` | Bulk task creation | @@ -131,14 +121,14 @@ Each process gets a registry entry for tracking and is managed through the web d The PowerShell MCP server (`dotbot-mcp.ps1`) exposes tools via stdio transport: -- **Task tools**: create, create-bulk, list, get-next, get-context, get-stats, answer-question, approve-split, mark-todo, mark-analysing, mark-analysed, mark-in-progress, mark-done, mark-needs-input, mark-skipped +- **Task tools**: create, create-bulk, get, list, get-next, get-context, update, set-status, mark-needs-review, submit-review - **Decision tools**: create, get, list, update, mark-accepted, mark-deprecated, mark-superseded - **Session tools**: initialize, update, get-state, get-stats, increment-completed - **Plan tools**: create, get, update - **Dev tools**: start, stop - **Steering**: heartbeat with whisper channel for operator interrupts -Tools are auto-discovered from `.bot/systems/mcp/tools/{tool-name}/` — each tool is a folder with `metadata.json` (schema) and `script.ps1` (implementation). +Tools are auto-discovered from `src/mcp/tools/{tool-name}/` — each tool is a folder with `metadata.json` (schema) and `script.ps1` (implementation). ## Usage @@ -150,7 +140,7 @@ dotbot go Opens the web UI on a random port in the IANA dynamic range (49152–65535) where you can: - View and manage tasks -- Start analysis and execution processes +- Start and stop workflow task runners - Monitor running processes - Kick off product planning @@ -163,11 +153,12 @@ From the dashboard, use the start-from-prompt workflow to: ### Autonomous Execution -Start analysis and execution processes from the dashboard. They run in a loop: +Start a workflow task runner from the dashboard or CLI. It runs in a loop: -1. **Analysis process** picks up `todo` tasks, analyses them, marks them `analysed` -2. **Execution process** picks up `analysed` tasks, implements them, marks them `done` -3. Completed tasks are squash-merged to main automatically +1. Picks the next eligible `todo` task from the workflow run +2. Runs the task in a single provider session, moving it to `in-progress` +3. Marks completed work `done`, or parks it as `needs-input`, `needs-review`, `failed`, `skipped`, or `cancelled` +4. Squash-merges completed task branches to main automatically ### Manual Task Management diff --git a/src/cli/RegistryManager.psm1 b/src/cli/RegistryManager.psm1 new file mode 100644 index 00000000..8514de4e --- /dev/null +++ b/src/cli/RegistryManager.psm1 @@ -0,0 +1,132 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Registry state management helpers for dotbot. + +.DESCRIPTION + Provides functions for reading registry metadata and auto-updating stale + git-based registries. Consumed by dotbot init and dotbot run. +#> + +$ErrorActionPreference = "Stop" + +function Get-DotbotRegistries { + <# + .SYNOPSIS + Returns all registered registries from registries.json as an array. + Returns an empty array if the file does not exist or has no entries. + #> + param( + [Parameter(Mandatory)][string]$DotbotBase + ) + $configPath = Join-Path $DotbotBase "registries.json" + if (-not (Test-Path $configPath)) { return @() } + try { + $config = Get-Content $configPath -Raw | ConvertFrom-Json + if ($config.registries) { return @($config.registries) } + } catch { + Write-DotbotWarning "Failed to parse registries.json — skipping registry auto-update: $($_.Exception.Message)" + } + return @() +} + +function Update-StaleRegistries { + <# + .SYNOPSIS + Pulls the latest commits for all git-based registries that have + auto_update set to true. Silently skips local (symlink) registries. + + .DESCRIPTION + Called automatically by dotbot init and dotbot run. Failures are + non-fatal: a warning is emitted and the stale local copy is used. + + .PARAMETER DotbotBase + Resolved dotbot install path (DOTBOT_HOME). + + .PARAMETER MaxAgeSecs + Only update registries whose last update is older than this many + seconds. Defaults to 3600 (1 hour) to avoid hammering git on every + run. Pass 0 to force-update all eligible registries. + #> + param( + [Parameter(Mandatory)][string]$DotbotBase, + [int]$MaxAgeSecs = 3600 + ) + + $configPath = Join-Path $DotbotBase "registries.json" + $registries = Get-DotbotRegistries -DotbotBase $DotbotBase + if ($registries.Count -eq 0) { return } + + $config = Get-Content $configPath -Raw | ConvertFrom-Json + $changed = $false + + foreach ($entry in $registries) { + if (-not $entry.auto_update) { continue } + if ($entry.type -ne "git") { continue } + + # Validate name and ensure resolved path stays under the registries root + $rawName = [string]$entry.name + $safeName = [System.IO.Path]::GetFileName($rawName) + if ($safeName -ne $rawName -or $safeName -in @('.', '..') -or $safeName -notmatch '^[A-Za-z0-9._-]+$') { + Write-DotbotWarning "Registry name '$rawName' is invalid — skipping auto-update" + continue + } + $registriesRoot = [System.IO.Path]::GetFullPath((Join-Path $DotbotBase "registries")) + $registryPath = [System.IO.Path]::GetFullPath((Join-Path $registriesRoot $safeName)) + $rootWithSep = $registriesRoot.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + [System.IO.Path]::DirectorySeparatorChar + if (-not $registryPath.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-DotbotWarning "Registry '$safeName' resolves outside registries directory — skipping auto-update" + continue + } + + if (-not (Test-Path -LiteralPath $registryPath -PathType Container)) { + Write-DotbotWarning "Registry '$safeName' directory missing — skipping auto-update" + continue + } + + # Honour MaxAgeSecs: skip if updated recently + if ($MaxAgeSecs -gt 0 -and $entry.updated_at) { + try { + $lastUpdate = [datetime]::Parse($entry.updated_at) + $ageSecs = ([datetime]::UtcNow - $lastUpdate.ToUniversalTime()).TotalSeconds + if ($ageSecs -lt $MaxAgeSecs) { continue } + } catch { } + } + + # git fetch + fast-forward merge + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-DotbotWarning "git not found on PATH — skipping registry auto-update" + return + } + $branch = if ($entry.branch) { $entry.branch } else { "main" } + $null = & git -C $registryPath fetch --quiet origin $branch 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-DotbotWarning "Auto-update failed for registry '$safeName' (fetch error) — using cached copy" + continue + } + + $null = & git -C $registryPath merge --ff-only "origin/$branch" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-DotbotWarning "Auto-update failed for registry '$safeName' (cannot fast-forward) — using cached copy" + continue + } + + # Record updated_at timestamp + $config.registries = @($config.registries | ForEach-Object { + if ($_.name -eq $entry.name) { + $_ | Add-Member -NotePropertyName 'updated_at' -NotePropertyValue ((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) -Force + $changed = $true + } + $_ + }) + } + + if ($changed) { + $config | ConvertTo-Json -Depth 5 | Set-Content $configPath + } +} + +Export-ModuleMember -Function Get-DotbotRegistries, Update-StaleRegistries diff --git a/src/cli/registry-remove.ps1 b/src/cli/registry-remove.ps1 new file mode 100644 index 00000000..b0e2ee7d --- /dev/null +++ b/src/cli/registry-remove.ps1 @@ -0,0 +1,135 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Remove a registered dotbot extension registry. + +.DESCRIPTION + Removes a registry by name: deletes the local registry directory (cloned + repo or junction/symlink) and removes the entry from registries.json. + +.PARAMETER Name + Registry namespace to remove (e.g., "myorg"). + +.PARAMETER Force + Skip confirmation prompt. + +.EXAMPLE + registry-remove.ps1 -Name myorg + registry-remove.ps1 -Name myorg -Force +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Name, + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +Import-Module (Join-Path $PSScriptRoot ".." "runtime" "Modules" "Dotbot.Core" "Dotbot.Core.psm1") -Force -DisableNameChecking +$DotbotBase = Get-DotbotInstallPath +$RegistriesDir = Join-Path $DotbotBase "registries" +$ConfigPath = Join-Path $DotbotBase "registries.json" + +# Import platform functions (required for theme helpers) +$PlatformFunctionsModule = Join-Path $PSScriptRoot "Platform-Functions.psm1" +if (-not (Test-Path $PlatformFunctionsModule)) { + Write-Error "Required module not found: $PlatformFunctionsModule — run 'dotbot update' to repair" + exit 1 +} +Import-Module $PlatformFunctionsModule -Force -ErrorAction Stop +Import-Module (Join-Path (Get-DotbotInstallPath) "src" "runtime" "Modules" "Dotbot.Theme" "Dotbot.Theme.psd1") -Force -DisableNameChecking + +Write-DotbotBanner -Title "D O T B O T" -Subtitle "Registry: Remove" + +# Validate registry name to prevent path traversal +$safeName = [System.IO.Path]::GetFileName($Name) +if ($safeName -ne $Name -or $safeName -in @('.', '..') -or $safeName -notmatch '^[A-Za-z0-9._-]+$') { + Write-DotbotError "Invalid registry name '$Name'" + exit 1 +} +$RegistryPath = [System.IO.Path]::GetFullPath((Join-Path $RegistriesDir $Name)) +$rootWithSep = [System.IO.Path]::GetFullPath($RegistriesDir).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar +) + [System.IO.Path]::DirectorySeparatorChar +if (-not $RegistryPath.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-DotbotError "Invalid registry path '$RegistryPath'" + exit 1 +} + +# --------------------------------------------------------------------------- +# 1. Check registry exists in registries.json +# --------------------------------------------------------------------------- +if (-not (Test-Path $ConfigPath)) { + Write-DotbotError "No registries.json found — no registries have been added" + exit 1 +} + +$config = $null +try { + $config = Get-Content $ConfigPath -Raw | ConvertFrom-Json +} catch { + Write-DotbotError "Failed to parse registries.json: $_" + exit 1 +} + +if (-not $config.registries) { + Write-DotbotError "registries.json contains no registries" + exit 1 +} + +$entry = $config.registries | Where-Object { $_.name -eq $Name } +if (-not $entry) { + Write-DotbotError "Registry '$Name' is not registered" + Write-DotbotCommand "Run 'dotbot registry list' to see registered registries" + exit 1 +} + +# --------------------------------------------------------------------------- +# 2. Confirm removal +# --------------------------------------------------------------------------- +if (-not $Force) { + Write-DotbotWarning "This will remove registry '$Name' and delete its local files" + Write-DotbotLabel -Label "Source" -Value "$($entry.source)" + Write-DotbotLabel -Label "Type " -Value "$($entry.type)" + Write-BlankLine + if (-not (Read-DotbotConfirmation -Message "Remove registry '$Name'?" -Default $false)) { + Write-DotbotWarning "Aborted" + exit 0 + } +} + +# --------------------------------------------------------------------------- +# 3. Delete the local registry directory / symlink / junction +# --------------------------------------------------------------------------- +if (Test-Path $RegistryPath) { + $item = Get-Item -LiteralPath $RegistryPath -Force + # Junctions and symlinks must be removed without -Recurse to avoid + # deleting the target contents + $isJunctionOrSymlink = ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + if ($isJunctionOrSymlink) { + Write-Status "Removing symlink/junction: $RegistryPath" + $item.Delete() + } else { + Write-Status "Removing registry directory: $RegistryPath" + Remove-Item -Path $RegistryPath -Recurse -Force + } + Write-Success "Removed local registry files" +} else { + Write-DotbotWarning "Registry directory not found at $RegistryPath — skipping file removal" +} + +# --------------------------------------------------------------------------- +# 4. Remove entry from registries.json +# --------------------------------------------------------------------------- +$config.registries = @($config.registries | Where-Object { $_.name -ne $Name }) +$config | ConvertTo-Json -Depth 5 | Set-Content $ConfigPath +Write-Success "Removed '$Name' from registries.json" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +Write-BlankLine +Write-DotbotBanner -Title "Registry '$Name' removed" +Write-BlankLine diff --git a/src/cli/serve.ps1 b/src/cli/serve.ps1 index 236f0327..fcf04d7e 100644 --- a/src/cli/serve.ps1 +++ b/src/cli/serve.ps1 @@ -89,7 +89,7 @@ $cleanupRan = $false $cleanup = { if ($script:cleanupRan) { return } $script:cleanupRan = $true - try { Stop-DotbotRuntime -BotRoot $botRoot -Listener $start.listener -ControlPlaneRegistration $start.control_plane -ErrorAction SilentlyContinue } catch { $null = $_ } + try { Stop-DotbotRuntime -BotRoot $botRoot -Listener $start.listener -ControlPlaneRegistration $start.control_plane -EventConsumer $start.events_consumer -ErrorAction SilentlyContinue } catch { $null = $_ } } try { [Console]::CancelKeyPress.Add({ param($s, $e) $e.Cancel = $true; & $cleanup }) diff --git a/src/mcp/Resolve-ProjectRoot.ps1 b/src/mcp/Resolve-ProjectRoot.ps1 index a463ec59..4e0338d1 100644 --- a/src/mcp/Resolve-ProjectRoot.ps1 +++ b/src/mcp/Resolve-ProjectRoot.ps1 @@ -18,6 +18,17 @@ returns the path to the main repo's `.git/` regardless of whether the caller is inside the main checkout or a linked worktree. The walk-up is kept as a fallback for the no-git case (test fixtures, etc.). + + DOTBOT_STATE_ROOT vs DOTBOT_PROJECT_ROOT (issue #515): during task + execution the agent's working directory is a linked worktree, but dotbot's + runtime/task state lives in the *main* repository. Overloading + DOTBOT_PROJECT_ROOT for both meanings forced state resolution onto the + worktree, whose `.bot/.control` junction can be stale during retry/teardown + windows — the MCP server then resolves runtime.json to a dead link and + exits. DOTBOT_STATE_ROOT carries the stable main root explicitly and takes + precedence here, so state resolution never depends on a worktree junction. + When unset, the resolver falls back to DOTBOT_PROJECT_ROOT (backward + compatible) and then to git detection. #> function Resolve-DotbotProjectRoot { @@ -27,24 +38,37 @@ function Resolve-DotbotProjectRoot { [string]$StartPath ) - $envProjectRoot = [Environment]::GetEnvironmentVariable('DOTBOT_PROJECT_ROOT') - if (-not [string]::IsNullOrWhiteSpace($envProjectRoot)) { - $envProjectRoot = $envProjectRoot.Trim() - if ($envProjectRoot -eq '~') { - $envProjectRoot = $HOME - } elseif ($envProjectRoot.StartsWith('~/') -or $envProjectRoot.StartsWith('~\')) { - $envProjectRoot = Join-Path $HOME $envProjectRoot.Substring(2) + # Normalise an env-var path: expand ~, make absolute, and require it to be + # an existing directory. Returns $null when the value cannot be used. + $resolveEnvRoot = { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + $Value = $Value.Trim() + if ($Value -eq '~') { + $Value = $HOME + } elseif ($Value.StartsWith('~/') -or $Value.StartsWith('~\')) { + $Value = Join-Path $HOME $Value.Substring(2) } - try { - $envProjectRoot = [System.IO.Path]::GetFullPath($envProjectRoot) + $Value = [System.IO.Path]::GetFullPath($Value) } catch { return $null } + if (Test-Path -LiteralPath $Value -PathType Container) { return $Value } + return $null + } - if (Test-Path -LiteralPath $envProjectRoot -PathType Container) { - return $envProjectRoot - } + # DOTBOT_STATE_ROOT wins when it points at a real directory. It is the + # stable main root, immune to worktree junction staleness (#515). A set-but- + # invalid value falls through rather than failing, so a misconfigured state + # root degrades to the previous DOTBOT_PROJECT_ROOT/git behaviour. + $stateRoot = & $resolveEnvRoot ([Environment]::GetEnvironmentVariable('DOTBOT_STATE_ROOT')) + if ($stateRoot) { return $stateRoot } + + $envProjectRoot = [Environment]::GetEnvironmentVariable('DOTBOT_PROJECT_ROOT') + if (-not [string]::IsNullOrWhiteSpace($envProjectRoot)) { + $resolvedProjectRoot = & $resolveEnvRoot $envProjectRoot + if ($resolvedProjectRoot) { return $resolvedProjectRoot } return $null } diff --git a/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 new file mode 100644 index 00000000..f738ba7c --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 @@ -0,0 +1,41 @@ +@{ + RootModule = 'Dotbot.Events.psm1' + ModuleVersion = '1.0.0' + GUID = 'c4e1f8a2-3b6d-4e9c-8a17-2f5b9d3c7e04' + Author = 'dotbot contributors' + Description = 'Event-bus sinks. Folder-discovered subscribers that react to bus events on the activity log. Discovery scans /Plugins/Events/Sinks/*/metadata.json; Dispatch runs Invoke-Sink in a time-boxed child runspace, non-aborting and out-of-band; a background consumer tails activity.jsonl by persisted byte cursor.' + PowerShellVersion = '7.0' + + # Concerns live as nested modules so each is findable in isolation. + NestedModules = @( + 'Private/Discovery.psm1', + 'Private/Dispatch.psm1', + 'Private/Consumer.psm1' + ) + + FunctionsToExport = @( + # Discovery + 'Get-DefaultSinksDirectory' + 'Read-SinkMetadata' + 'Get-SinkRegistry' + 'Get-SinksForEvent' + + # Dispatch + 'Invoke-SingleSink' + 'Invoke-EventSinks' + + # Consumer + 'Get-EventCursorPath' + 'Read-EventCursor' + 'Save-EventCursor' + 'Initialize-EventConsumerCursor' + 'Read-EventBatch' + 'Invoke-EventConsumerTick' + 'Start-EventConsumer' + 'Stop-EventConsumer' + ) + + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() +} diff --git a/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psm1 b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psm1 new file mode 100644 index 00000000..639c0621 --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psm1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS +Dotbot.Events root module — event-bus sinks. + +The actual surface lives under Private/*.psm1 (Discovery today; Dispatch and +the background Consumer land in later steps). This root file exists so the psd1 +has a RootModule to point at; it deliberately exports nothing of its own. The +plugin contract and shipped sinks live on disk under +/Plugins/Events/Sinks/* and are picked up at dispatch time. +#> + +# Intentionally empty: nested modules carry the surface. diff --git a/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 new file mode 100644 index 00000000..a68784d8 --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 @@ -0,0 +1,367 @@ +<# +.SYNOPSIS +Background consumer for the event bus. + +Tails /.control/activity.jsonl by BYTE CURSOR — the same read +technique the /api/activity/tail endpoint uses — and dispatches each event to +its matching sinks (out-of-band: only after the event is durably on disk). + +Delivery is AT-LEAST-ONCE: the persisted cursor advances only AFTER a batch has +been dispatched, so a crash mid-batch re-delivers that batch on the next tick +rather than dropping it. The cursor lives at /.control/events-cursor.json +and survives restarts, so events appended while no runtime was running (e.g. a +detached CLI `tasks run`) are delivered from the persisted offset when the next +runtime starts — delayed, never dropped. + +This module is self-contained: it computes the activity-log path itself rather +than importing Dotbot.Runtime, so the dependency stays one-directional +(Dotbot.Runtime hosts Dotbot.Events, never the reverse). + +The runspace lifecycle (Start/Stop-EventConsumer) mirrors the runtime's +ControlPlaneClient: a cooperative stop flag + explicit Stop()/Dispose(). +#> + +# ─── Paths ────────────────────────────────────────────────────────────────── + +function _Get-EventActivityLogPath { + # Duplicated (not imported from Dotbot.Runtime) to keep the dependency + # one-directional. Must stay in sync with Get-ActivityLogPath. + param([Parameter(Mandatory)] [string]$BotRoot) + return Join-Path $BotRoot (Join-Path '.control' 'activity.jsonl') +} + +function Get-EventCursorPath { + <# + .SYNOPSIS + Resolve /.control/events-cursor.json (the persisted byte offset). + #> + param([Parameter(Mandatory)] [string]$BotRoot) + return Join-Path $BotRoot (Join-Path '.control' 'events-cursor.json') +} + +# ─── Cursor persistence ───────────────────────────────────────────────────── + +function Read-EventCursor { + <# + .SYNOPSIS + Return the persisted byte offset, or $null when no cursor exists yet. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$BotRoot) + + $path = Get-EventCursorPath -BotRoot $BotRoot + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } + try { + $obj = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($null -ne $obj -and $null -ne $obj.offset) { return [long]$obj.offset } + } catch { + # Corrupt cursor → treat as fresh; the caller re-initialises. + } + return $null +} + +function Save-EventCursor { + <# + .SYNOPSIS + Persist the byte offset. Atomic (temp + move) so a crash can't leave a + half-written cursor. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + [Parameter(Mandatory)] [long]$Offset + ) + + $path = Get-EventCursorPath -BotRoot $BotRoot + $dir = Split-Path -Parent $path + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + $json = ([ordered]@{ offset = $Offset } | ConvertTo-Json -Compress) + $tmp = "$path.tmp" + [System.IO.File]::WriteAllText($tmp, $json, [System.Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $tmp -Destination $path -Force +} + +function Initialize-EventConsumerCursor { + <# + .SYNOPSIS + Ensure a cursor exists. On the very first start (no cursor) seed it to the + current end of the activity log so historical events are NOT replayed to + sinks (a webhook re-POSTing the whole history would be surprising). From + then on the persisted cursor carries forward across restarts. + + .OUTPUTS + The byte offset the consumer will start reading from. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$BotRoot) + + $existing = Read-EventCursor -BotRoot $BotRoot + if ($null -ne $existing) { return $existing } + + $logPath = _Get-EventActivityLogPath -BotRoot $BotRoot + $eof = 0L + if (Test-Path -LiteralPath $logPath -PathType Leaf) { + $eof = [long]((Get-Item -LiteralPath $logPath).Length) + } + Save-EventCursor -BotRoot $BotRoot -Offset $eof + return $eof +} + +# ─── Byte-cursor read ─────────────────────────────────────────────────────── + +function Read-EventBatch { + <# + .SYNOPSIS + Read all complete JSON lines from $Offset to EOF and report the new byte + position. Mirrors the /api/activity/tail streaming read (FileStream opened + ReadWrite-shared, Seek to the offset, read to EOF, report Position). + + .OUTPUTS + @{ events = @(, ...); position = } + + Malformed lines are skipped. A cursor past EOF (log truncated/rotated) + restarts from 0. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$LogPath, + [Parameter(Mandatory)] [long]$Offset + ) + + if (-not (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + return @{ events = @(); position = $Offset } + } + + $events = @() + $newPos = $Offset + $fs = $null + $reader = $null + try { + $fs = [System.IO.FileStream]::new( + $LogPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite) + + $start = if ($Offset -gt $fs.Length) { 0L } else { $Offset } + [void]$fs.Seek($start, [System.IO.SeekOrigin]::Begin) + + $reader = [System.IO.StreamReader]::new($fs, [System.Text.UTF8Encoding]::new($false)) + $text = $reader.ReadToEnd() + $newPos = $fs.Position + + foreach ($line in ($text -split "`n")) { + $trimmed = $line.Trim() + if (-not $trimmed) { continue } + try { $events += ($trimmed | ConvertFrom-Json -ErrorAction Stop) } catch { } + } + } finally { + # Disposing the reader disposes the underlying stream too. + if ($reader) { $reader.Dispose() } elseif ($fs) { $fs.Dispose() } + } + + return @{ events = @($events); position = $newPos } +} + +# ─── One delivery tick ────────────────────────────────────────────────────── + +function _Get-MergedSettingsSafe { + # Resolve the full merged settings for the sink Context. Guarded so the + # tick still works when Dotbot.Settings isn't loaded (isolated tests). + param([Parameter(Mandatory)] [string]$BotRoot) + + if (-not (Get-Command Get-MergedSettings -ErrorAction SilentlyContinue)) { return $null } + try { + return Get-MergedSettings -BotRoot $BotRoot + } catch { + $null = $_ + } + return $null +} + +function Invoke-EventConsumerTick { + <# + .SYNOPSIS + Read the batch since the persisted cursor, dispatch each event to its + sinks, then advance the cursor. At-least-once: the cursor is saved only + after dispatch. + + .PARAMETER Registry + Pre-discovered sink registry (Get-SinkRegistry). Passed in so the tick does + not re-scan disk on every cycle. + + .OUTPUTS + @{ processed = ; dispatched = ; position = } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + $Registry, + [string]$LogPath + ) + + if (-not $LogPath) { $LogPath = _Get-EventActivityLogPath -BotRoot $BotRoot } + + $offset = Read-EventCursor -BotRoot $BotRoot + if ($null -eq $offset) { $offset = 0L } + + $batch = Read-EventBatch -LogPath $LogPath -Offset $offset + + # Resolve the sink Context once per tick (fresh settings each cycle so an + # operator's config edit takes effect without a restart). Sinks read the + # section they need: webhooks → Settings.events.webhooks, mothership → + # Settings.mothership + Settings.events.mothership. + $context = @{ BotRoot = $BotRoot; Settings = (_Get-MergedSettingsSafe -BotRoot $BotRoot) } + + $dispatchedTotal = 0 + foreach ($evt in $batch.events) { + # Invoke-EventSinks is non-aborting and never throws, but guard anyway + # so a single event can never wedge the cursor. + try { + $r = Invoke-EventSinks -Event $evt -Registry $Registry -BotRoot $BotRoot -Context $context + $dispatchedTotal += [int]$r.dispatched + } catch { + $null = $_ + } + } + + # Advance the cursor only after dispatch (at-least-once). + Save-EventCursor -BotRoot $BotRoot -Offset $batch.position + + return @{ + processed = @($batch.events).Count + dispatched = $dispatchedTotal + position = $batch.position + } +} + +# ─── Runspace lifecycle (mirrors ControlPlaneClient) ──────────────────────── + +function _Test-EventConsumerEnabled { + # Master kill-switch: events.enabled. Defaults to on when settings are + # unavailable (e.g. isolated unit tests import only Dotbot.Events). + param([Parameter(Mandatory)] [string]$BotRoot) + + if (-not (Get-Command Get-MergedSettings -ErrorAction SilentlyContinue)) { return $true } + try { + $settings = Get-MergedSettings -BotRoot $BotRoot + if ($null -ne $settings -and $null -ne $settings.events -and $settings.events.enabled -eq $false) { + return $false + } + } catch { + $null = $_ + } + return $true +} + +function Start-EventConsumer { + <# + .SYNOPSIS + Start the background consumer on a dedicated runspace. Returns a handle for + Stop-EventConsumer, or $null when the bus is disabled. + + .DESCRIPTION + Discovers sinks once, seeds the cursor to EOF on first-ever start, then + loops Invoke-EventConsumerTick on the runspace until the stop flag is set. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + [double]$IntervalSeconds = 2 + ) + + if (-not (_Test-EventConsumerEnabled -BotRoot $BotRoot)) { + return $null + } + + # Discover sinks once in the parent (fail-loud happens here at startup). + $registry = @(Get-SinkRegistry -BotRoot $BotRoot) + + # Seed the cursor so we never replay history to sinks on first-ever start. + Initialize-EventConsumerCursor -BotRoot $BotRoot | Out-Null + + $modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Dotbot.Events.psd1' + # Dotbot.Settings sits alongside Dotbot.Events under Modules/. Import it in + # the loop so Invoke-EventConsumerTick can resolve the sink Context's + # `events` config each cycle. + $settingsPath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) (Join-Path 'Dotbot.Settings' 'Dotbot.Settings.psd1') + + # Single-element bool array passed by reference into the runspace as the + # cooperative stop signal. + $stopFlag = [bool[]]::new(1) + + $loop = { + param([string]$BotRoot, $Registry, [double]$IntervalSeconds, [string]$ModulePath, [string]$SettingsPath, [bool[]]$StopFlag) + + Import-Module $ModulePath -DisableNameChecking -Global -ErrorAction SilentlyContinue + Import-Module $SettingsPath -DisableNameChecking -Global -ErrorAction SilentlyContinue + + while (-not $StopFlag[0]) { + try { + Invoke-EventConsumerTick -BotRoot $BotRoot -Registry $Registry | Out-Null + } catch { + $null = $_ + } + # Chunked sleep so a stop request is honoured promptly. + $slept = 0.0 + while ($slept -lt $IntervalSeconds -and -not $StopFlag[0]) { + Start-Sleep -Milliseconds 200 + $slept += 0.2 + } + } + } + + $runspace = [runspacefactory]::CreateRunspace() + $runspace.Open() + $ps = [powershell]::Create() + $ps.Runspace = $runspace + $null = $ps.AddScript($loop) + $null = $ps.AddArgument($BotRoot) + $null = $ps.AddArgument($registry) + $null = $ps.AddArgument($IntervalSeconds) + $null = $ps.AddArgument($modulePath) + $null = $ps.AddArgument($settingsPath) + $null = $ps.AddArgument($stopFlag) + $async = $ps.BeginInvoke() + + return @{ + enabled = $true + stop_flag = $stopFlag + ps = $ps + runspace = $runspace + async = $async + registry = $registry + bot_root = $BotRoot + } +} + +function Stop-EventConsumer { + <# + .SYNOPSIS + Signal the consumer loop to stop and dispose its runspace. Idempotent. + #> + [CmdletBinding()] + param($Consumer) + + if ($null -eq $Consumer) { return } + + # Use `$null -ne` explicitly: a [bool[]] of length 1 evaluates to its single + # element in a boolean context, so `if ($Consumer.stop_flag)` would be + # $false when the flag is still down and never set it. + try { if ($null -ne $Consumer.stop_flag) { $Consumer.stop_flag[0] = $true } } catch { $null = $_ } + try { if ($null -ne $Consumer.ps) { $Consumer.ps.Stop(); $Consumer.ps.Dispose() } } catch { $null = $_ } + try { if ($null -ne $Consumer.runspace) { $Consumer.runspace.Close(); $Consumer.runspace.Dispose() } } catch { $null = $_ } +} + +Export-ModuleMember -Function @( + 'Get-EventCursorPath' + 'Read-EventCursor' + 'Save-EventCursor' + 'Initialize-EventConsumerCursor' + 'Read-EventBatch' + 'Invoke-EventConsumerTick' + 'Start-EventConsumer' + 'Stop-EventConsumer' +) diff --git a/src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1 new file mode 100644 index 00000000..3cd5967c --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1 @@ -0,0 +1,250 @@ +<# +.SYNOPSIS +Discovery for event-bus sinks. + +Sinks live one-folder-per-sink under a stable directory. Each folder must +contain metadata.json + script.ps1. Discovery scans the folder, parses each +metadata.json, and returns a list of sink records sorted alphabetically by +folder name — dispatch order is the declaration order in the directory listing. + +A malformed metadata.json is reported as an error rather than silently +skipped, so a fixture directory with valid sinks + one malformed produces a +startup error (parity with the Dotbot.Hook engine). + +Sinks differ from transition hooks in two ways enforced elsewhere (Dispatch): +dispatch is non-aborting and out-of-band. Consequently a sink's metadata has +no `abort_on_failure` field — every sink is non-aborting by contract. +#> + +# ─── Configurable schema ──────────────────────────────────────────────────── + +$script:DotbotSinkMetadataRequiredFields = @( + 'name', + 'subscribed_events', + 'max_duration' +) + +# ─── Default sinks directory resolution ───────────────────────────────────── + +function Get-DefaultSinksDirectory { + <# + .SYNOPSIS + Resolve the canonical "where do sinks live" path for a project. + + .DESCRIPTION + Sinks live under runtime/Plugins/Events/Sinks/. + After dotbot init, this is /src/runtime/Plugins/Events/Sinks/. + When running against an uninstalled source tree (dev tests), the sinks + live next to this module in /src/runtime/Plugins/Events/Sinks/. + + Resolution order: + 1. /src/runtime/Plugins/Events/Sinks/ ← per-project framework copy + 2. /../../Plugins/Events/Sinks/ ← dev/repo fallback + + Returns $null if neither exists. Callers can pass an explicit -SinksDir + to Get-SinkRegistry to override. + #> + [CmdletBinding()] + param( + [string]$BotRoot + ) + + if ($BotRoot) { + $projectCopy = Join-Path $BotRoot (Join-Path 'src' (Join-Path 'runtime' (Join-Path 'Plugins' (Join-Path 'Events' 'Sinks')))) + if (Test-Path -LiteralPath $projectCopy -PathType Container) { + return $projectCopy + } + } + + # Dev fallback: this file lives at + # /src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1, + # so the sinks sit at /src/runtime/Plugins/Events/Sinks/. + $repoCopy = Join-Path (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))) (Join-Path 'Plugins' (Join-Path 'Events' 'Sinks')) + if (Test-Path -LiteralPath $repoCopy -PathType Container) { + return $repoCopy + } + + return $null +} + +# ─── Metadata parsing ─────────────────────────────────────────────────────── + +function _Parse-SinkMetadataJson { + <# + .SYNOPSIS + Parse a metadata.json string into a hashtable. + #> + param([Parameter(Mandatory)] [string]$Content) + + try { + return ($Content | ConvertFrom-Json -AsHashtable) + } catch { + throw "Invalid metadata.json: $($_.Exception.Message)" + } +} + +function Read-SinkMetadata { + <# + .SYNOPSIS + Parse and validate a single sink's metadata.json. + + .OUTPUTS + Hashtable record: + @{ + name = 'webhooks' + description = '...' + subscribed_events = @('task.*', 'workflow.*') + max_duration = 10 + metadata_path = '/path/to/metadata.json' + script_path = '/path/to/script.ps1' + dir = '/path/to/webhooks' + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$SinkDir + ) + + if (-not (Test-Path -LiteralPath $SinkDir -PathType Container)) { + throw "Read-SinkMetadata: sink directory not found: $SinkDir" + } + + $metaPath = Join-Path $SinkDir 'metadata.json' + $scriptPath = Join-Path $SinkDir 'script.ps1' + + if (-not (Test-Path -LiteralPath $metaPath -PathType Leaf)) { + throw "Read-SinkMetadata: '$SinkDir' is missing metadata.json." + } + if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) { + throw "Read-SinkMetadata: '$SinkDir' is missing script.ps1." + } + + $raw = Get-Content -LiteralPath $metaPath -Raw + $parsed = _Parse-SinkMetadataJson -Content $raw + if (-not $parsed -or $parsed.Count -eq 0) { + throw "Read-SinkMetadata: '$metaPath' did not parse to any fields." + } + + foreach ($req in $script:DotbotSinkMetadataRequiredFields) { + if (-not $parsed.ContainsKey($req)) { + throw "Read-SinkMetadata: '$metaPath' is missing required field '$req'." + } + } + + # Normalise subscribed_events → string[]. Entries are glob patterns matched + # against a concrete dotted event type (e.g. 'task.*' matches 'task.created'). + # No closed vocabulary: the bus is extensible, so any non-empty pattern is + # legal here — an unknown family just never matches until it is published. + $events = @($parsed['subscribed_events']) + if ($events.Count -eq 0) { + throw "Read-SinkMetadata: '$metaPath' has empty subscribed_events (must list at least one glob pattern)." + } + foreach ($e in $events) { + if ([string]::IsNullOrWhiteSpace([string]$e)) { + throw "Read-SinkMetadata: '$metaPath' has a blank entry in subscribed_events." + } + } + + # Normalise max_duration → int seconds + $maxDur = [int]$parsed['max_duration'] + if ($maxDur -le 0) { + throw "Read-SinkMetadata: '$metaPath' has non-positive max_duration ($($parsed['max_duration']))." + } + + $description = if ($parsed.ContainsKey('description')) { [string]$parsed['description'] } else { '' } + + return [ordered]@{ + name = [string]$parsed['name'] + description = $description + subscribed_events = [string[]]$events + max_duration = $maxDur + metadata_path = $metaPath + script_path = $scriptPath + dir = $SinkDir + } +} + +# ─── Registry assembly ────────────────────────────────────────────────────── + +function Get-SinkRegistry { + <# + .SYNOPSIS + Scan the sinks directory, parse each sink's metadata, return a sorted + list of sink records. + + .DESCRIPTION + Discovery is deterministic and reproducible. Order is alphabetical by + directory name. A malformed sink (bad/missing metadata, missing script.ps1) + throws — discovery is "either all parse correctly or fail loudly" so a typo + at startup is impossible to miss. + + .PARAMETER SinksDir + Override the sinks root. When omitted, Get-DefaultSinksDirectory chooses. + + .PARAMETER BotRoot + Project bot root, used by Get-DefaultSinksDirectory to find the per-project + framework copy. + + .OUTPUTS + @(, ...). Empty array if no sinks dir exists. + #> + [CmdletBinding()] + param( + [string]$SinksDir, + [string]$BotRoot + ) + + if (-not $SinksDir) { + $SinksDir = Get-DefaultSinksDirectory -BotRoot $BotRoot + } + if (-not $SinksDir) { return @() } + if (-not (Test-Path -LiteralPath $SinksDir -PathType Container)) { return @() } + + # Collect with a foreach statement (not ForEach-Object) so accumulation + # stays in this scope, and emit the elements normally — callers wrap the + # result in @() so 0/1/many sinks all read back as an array. + $registry = @() + foreach ($dir in (Get-ChildItem -LiteralPath $SinksDir -Directory -ErrorAction SilentlyContinue | Sort-Object -Property Name)) { + # Read-SinkMetadata throws on malformed entries; let it propagate. + $registry += (Read-SinkMetadata -SinkDir $dir.FullName) + } + return $registry +} + +function Get-SinksForEvent { + <# + .SYNOPSIS + Filter a sink registry down to sinks subscribed to a concrete event type. + + .DESCRIPTION + A sink matches when any of its subscribed_events glob patterns matches the + given event type (PowerShell -like), so a sink subscribed to 'task.*' + matches 'task.created', and one subscribed to the exact 'workflow.run_completed' + matches only that type. + + .PARAMETER Registry + The output of Get-SinkRegistry. + + .PARAMETER EventType + The concrete dotted event type of a published event (e.g. 'task.created'). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Registry, + [Parameter(Mandatory)] [string]$EventType + ) + $out = @() + foreach ($s in $Registry) { + foreach ($pattern in $s.subscribed_events) { + if ($EventType -like $pattern) { $out += $s; break } + } + } + return $out +} + +Export-ModuleMember -Function @( + 'Get-DefaultSinksDirectory' + 'Read-SinkMetadata' + 'Get-SinkRegistry' + 'Get-SinksForEvent' +) diff --git a/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 new file mode 100644 index 00000000..3f9a8777 --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 @@ -0,0 +1,242 @@ +<# +.SYNOPSIS +Dispatch for event-bus sinks. + +Runs each sink subscribed to a published event. Two rules distinguish sinks +from transition hooks: + + - NON-ABORTING: a sink that fails or times out is logged and skipped. It + never rolls back a task and never stops the other sinks for the same event. + - OUT-OF-BAND: dispatch happens AFTER the event is durably logged, driven by + the background consumer (a later step) — never inside a task's + state-transition path. + +Each sink runs in a child runspace so max_duration can be enforced via Stop(). + +The Invoke-Sink contract from each sink's script.ps1: a function taking +$Event (the event envelope) and $Context (a hashtable with BotRoot and the +resolved `events` settings section — so a sink can read its own config without +importing anything into its isolated runspace). A sink may return a hashtable +with Success/Message; returning nothing is treated as success — its work is the +side effect (POST a webhook, forward to the mothership, …). + +Loading note: a bare .ps1 with a top-level Export-ModuleMember isn't a real +module. We turn it into one at dispatch time via New-Module against a +ScriptBlock built from the file contents, matching the Dotbot.Hook engine. +#> + +function Invoke-SingleSink { + <# + .SYNOPSIS + Run one sink's Invoke-Sink function under a timeout. Catches all faults and + normalises the return to a single hashtable. Never throws. + + .OUTPUTS + @{ + name = '' + success = $true|$false + message = '' + duration = + timed_out = $true|$false + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Sink, # one element from Get-SinkRegistry + [Parameter(Mandatory)] $Event, # the event envelope (hashtable or pscustomobject) + $Context # @{ BotRoot; Events } passed to the sink + ) + + $name = [string]$Sink.name + $maxDuration = [int]$Sink.max_duration + if ($null -eq $Context) { $Context = @{} } + + # Read the script once; pass the contents into the child runspace rather + # than re-reading from disk inside it (avoids coupling to a working dir). + $scriptContent = $null + try { + $scriptContent = Get-Content -LiteralPath $Sink.script_path -Raw -ErrorAction Stop + } catch { + return @{ + name = $name + success = $false + message = "Sink '$name': could not read script.ps1 — $($_.Exception.Message)" + duration = [TimeSpan]::Zero + timed_out = $false + } + } + + $runner = { + param([string]$Content, [string]$SinkName, $Event, $Context) + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + $sb = [ScriptBlock]::Create($Content) + # New-Module against the script block produces a real dynamic + # module — Export-ModuleMember inside the script works, and we can + # invoke its function via `& $mod `. + $mod = New-Module -Name ("DotbotSink_" + $SinkName) -ScriptBlock $sb + $sinkResult = & $mod Invoke-Sink -Event $Event -Context $Context + } catch { + $sw.Stop() + return @{ + success = $false + message = $_.Exception.Message + duration = $sw.Elapsed + } + } + $sw.Stop() + + # A sink that returns nothing is a success (the work is the side + # effect). If it returns a hashtable, honour Success/Message (PascalCase + # per contract, lowercase tolerated). + $success = $true + $message = '' + $sinkDuration = $sw.Elapsed + if ($sinkResult -is [hashtable]) { + if ($sinkResult.ContainsKey('Success')) { $success = [bool]$sinkResult['Success'] } + elseif ($sinkResult.ContainsKey('success')) { $success = [bool]$sinkResult['success'] } + if ($sinkResult.ContainsKey('Message')) { $message = [string]$sinkResult['Message'] } + elseif ($sinkResult.ContainsKey('message')) { $message = [string]$sinkResult['message'] } + } + return @{ + success = $success + message = $message + duration = $sinkDuration + } + } + + $ps = [PowerShell]::Create() + $null = $ps.AddScript($runner) + $null = $ps.AddArgument($scriptContent) + $null = $ps.AddArgument($name) + $null = $ps.AddArgument($Event) + $null = $ps.AddArgument($Context) + + $outerSw = [System.Diagnostics.Stopwatch]::StartNew() + $async = $ps.BeginInvoke() + + $completed = $async.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($maxDuration)) + $outerSw.Stop() + + if (-not $completed) { + # Timeout — forcibly stop the runspace. Non-aborting: the sink is + # marked failed/timed-out and the caller moves on to the next sink. + try { $ps.Stop() } catch { $null = $_ } + try { $ps.Dispose() } catch { $null = $_ } + return @{ + name = $name + success = $false + message = "Sink '$name' exceeded max_duration of ${maxDuration}s and was stopped." + duration = $outerSw.Elapsed + timed_out = $true + } + } + + $result = $null + try { + $result = $ps.EndInvoke($async) | Select-Object -First 1 + } catch { + $result = @{ success = $false; message = $_.Exception.Message; duration = $outerSw.Elapsed } + } finally { + try { $ps.Dispose() } catch { $null = $_ } + } + + if ($null -eq $result) { + $result = @{ success = $false; message = "Sink '$name' produced no result."; duration = $outerSw.Elapsed } + } + + return @{ + name = $name + success = [bool]$result.success + message = [string]$result.message + duration = if ($result.duration -is [TimeSpan]) { $result.duration } else { $outerSw.Elapsed } + timed_out = $false + } +} + +function Invoke-EventSinks { + <# + .SYNOPSIS + Dispatch every sink subscribed to a single event's type. Non-aborting: + every matching sink runs regardless of what the others do. + + .DESCRIPTION + Extracts the event's dotted `type`, finds the subscribed sinks, and runs + each in its own time-boxed child runspace. A sink failure or timeout is + captured in the results and never stops the remaining sinks — nor does it + ever propagate back to the caller. + + .PARAMETER Event + The event envelope — a hashtable (as published) or a pscustomobject (as + parsed from the activity log by the consumer). + + .PARAMETER Registry + A pre-discovered sink registry (from Get-SinkRegistry). When omitted, the + registry is discovered from -SinksDir / -BotRoot. The consumer discovers + once at startup and passes it here per event to avoid re-scanning disk. + + .PARAMETER Context + The @{ BotRoot; Events } context handed to each sink. When omitted, a + minimal @{ BotRoot = $BotRoot } is built. + + .OUTPUTS + @{ + event_type = '' | $null + dispatched = + results = @( , ... ) + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Event, + $Registry, + [string]$BotRoot, + [string]$SinksDir, + $Context + ) + + if ($null -eq $Context) { $Context = @{ BotRoot = $BotRoot } } + + $eventType = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } + if ([string]::IsNullOrWhiteSpace($eventType)) { + return @{ event_type = $null; dispatched = 0; results = @() } + } + + if ($null -eq $Registry) { + $Registry = @(Get-SinkRegistry -SinksDir $SinksDir -BotRoot $BotRoot) + } + + $matching = @(Get-SinksForEvent -Registry $Registry -EventType $eventType) + + $results = @() + foreach ($s in $matching) { + # Non-aborting belt-and-braces: Invoke-SingleSink already swallows sink + # faults, but guard the dispatch call itself too so one bad sink can + # never stop the others. + $r = $null + try { + $r = Invoke-SingleSink -Sink $s -Event $Event -Context $Context + } catch { + $r = @{ + name = [string]$s.name + success = $false + message = "Sink dispatch error: $($_.Exception.Message)" + duration = [TimeSpan]::Zero + timed_out = $false + } + } + $results += ,$r + } + + return @{ + event_type = $eventType + dispatched = $matching.Count + results = $results + } +} + +Export-ModuleMember -Function @( + 'Invoke-SingleSink' + 'Invoke-EventSinks' +) diff --git a/src/runtime/Modules/Dotbot.Harness/Adapters/ClaudeCodeAdapter.ps1 b/src/runtime/Modules/Dotbot.Harness/Adapters/ClaudeCodeAdapter.ps1 index 88cc60e7..e2ddb57b 100644 --- a/src/runtime/Modules/Dotbot.Harness/Adapters/ClaudeCodeAdapter.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Adapters/ClaudeCodeAdapter.ps1 @@ -205,6 +205,10 @@ function Invoke-ClaudeCodeAdapterStream { $mcpProjectRoot = if ($WorkingDirectory) { $WorkingDirectory } elseif ($psi.WorkingDirectory) { $psi.WorkingDirectory } else { $global:DotbotProjectRoot } if ($frameworkRootForMcp) { $psi.Environment["DOTBOT_HOME"] = $frameworkRootForMcp } if ($mcpProjectRoot) { $psi.Environment["DOTBOT_PROJECT_ROOT"] = $mcpProjectRoot } + # Runtime/task state lives in the main repo, not the worktree. Pin the + # MCP server's state resolution to the stable root so it never depends + # on the worktree's .control junction being valid (#515). + if ($global:DotbotProjectRoot) { $psi.Environment["DOTBOT_STATE_ROOT"] = $global:DotbotProjectRoot } # Claude Code's MCP client has a short default connection timeout (~5s). The dotbot stdio MCP # server cold-starts in 12-30s, so claude.exe's own MCP init fires before mcp__dotbot__* tools diff --git a/src/runtime/Modules/Dotbot.Harness/Adapters/CodexAdapter.ps1 b/src/runtime/Modules/Dotbot.Harness/Adapters/CodexAdapter.ps1 index ff77e184..e45c7a43 100644 --- a/src/runtime/Modules/Dotbot.Harness/Adapters/CodexAdapter.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Adapters/CodexAdapter.ps1 @@ -209,6 +209,15 @@ function Add-CodexWorktreeArgs { $frameworkRoot = Get-DotbotInstallPath $mcpScript = Join-Path $frameworkRoot 'src/mcp/dotbot-mcp.ps1' + # Pin MCP state resolution to the stable main root so it never depends on + # the worktree's .control junction being valid during retry/teardown (#515). + $envEntries = @( + ('DOTBOT_HOME={0}' -f (ConvertTo-CodexTomlString $frameworkRoot)) + ('DOTBOT_PROJECT_ROOT={0}' -f (ConvertTo-CodexTomlString $WorkingDirectory)) + ) + if ($global:DotbotProjectRoot) { + $envEntries += ('DOTBOT_STATE_ROOT={0}' -f (ConvertTo-CodexTomlString $global:DotbotProjectRoot)) + } $worktreeArgs = @( '-C', $WorkingDirectory, '-c', ('mcp_servers.dotbot.command={0}' -f (ConvertTo-CodexTomlString 'pwsh')), @@ -219,9 +228,7 @@ function Add-CodexWorktreeArgs { (ConvertTo-CodexTomlString '-File'), (ConvertTo-CodexTomlString $mcpScript) )), - '-c', ('mcp_servers.dotbot.env={{DOTBOT_HOME={0}, DOTBOT_PROJECT_ROOT={1}}}' -f ` - (ConvertTo-CodexTomlString $frameworkRoot), ` - (ConvertTo-CodexTomlString $WorkingDirectory)) + '-c', ('mcp_servers.dotbot.env={{{0}}}' -f ($envEntries -join ', ')) ) if ($CliArgs.Count -gt 0 -and $CliArgs[0] -eq 'exec') { diff --git a/src/runtime/Modules/Dotbot.Harness/Adapters/CopilotAdapter.ps1 b/src/runtime/Modules/Dotbot.Harness/Adapters/CopilotAdapter.ps1 index b859233d..9aabe6ef 100644 --- a/src/runtime/Modules/Dotbot.Harness/Adapters/CopilotAdapter.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Adapters/CopilotAdapter.ps1 @@ -72,6 +72,12 @@ function ConvertTo-CopilotMcpConfigJson { } } + # MCP state resolution targets the stable main root, not the worktree + # junction which can be stale during retry/teardown windows (#515). + if ($global:DotbotProjectRoot) { + $config.mcpServers.dotbot.env['DOTBOT_STATE_ROOT'] = $global:DotbotProjectRoot + } + return ($config | ConvertTo-Json -Compress -Depth 8) } diff --git a/src/runtime/Modules/Dotbot.Harness/Private/ConsoleRender.ps1 b/src/runtime/Modules/Dotbot.Harness/Private/ConsoleRender.ps1 index 13a1a469..1d9bddf5 100644 --- a/src/runtime/Modules/Dotbot.Harness/Private/ConsoleRender.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Private/ConsoleRender.ps1 @@ -261,6 +261,7 @@ function Invoke-WithHarnessProcessContext { $pushedLocation = $false $savedProjectRoot = $env:DOTBOT_PROJECT_ROOT + $savedStateRoot = $env:DOTBOT_STATE_ROOT $savedDotbotHome = $env:DOTBOT_HOME try { @@ -268,6 +269,9 @@ function Invoke-WithHarnessProcessContext { Push-Location -LiteralPath $WorkingDirectory $pushedLocation = $true $env:DOTBOT_PROJECT_ROOT = $WorkingDirectory + # State resolution stays pinned to the stable main root so in-process + # MCP calls don't follow the worktree junction (#515). + if ($global:DotbotProjectRoot) { $env:DOTBOT_STATE_ROOT = $global:DotbotProjectRoot } } $frameworkRoot = Get-DotbotInstallPath @@ -278,6 +282,9 @@ function Invoke-WithHarnessProcessContext { if ($null -ne $savedProjectRoot) { $env:DOTBOT_PROJECT_ROOT = $savedProjectRoot } else { Remove-Item Env:DOTBOT_PROJECT_ROOT -ErrorAction SilentlyContinue } + if ($null -ne $savedStateRoot) { $env:DOTBOT_STATE_ROOT = $savedStateRoot } + else { Remove-Item Env:DOTBOT_STATE_ROOT -ErrorAction SilentlyContinue } + if ($null -ne $savedDotbotHome) { $env:DOTBOT_HOME = $savedDotbotHome } else { Remove-Item Env:DOTBOT_HOME -ErrorAction SilentlyContinue } diff --git a/src/runtime/Modules/Dotbot.Harness/Private/ProcessStream.ps1 b/src/runtime/Modules/Dotbot.Harness/Private/ProcessStream.ps1 index 86174617..538b1d8e 100644 --- a/src/runtime/Modules/Dotbot.Harness/Private/ProcessStream.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Private/ProcessStream.ps1 @@ -66,6 +66,11 @@ function Invoke-HarnessProcessStream { if ($mcpProjectRoot) { $psi.Environment["DOTBOT_PROJECT_ROOT"] = $mcpProjectRoot } + # Pin MCP state resolution to the stable main root, independent of the + # worktree junction's validity (#515). + if ($global:DotbotProjectRoot) { + $psi.Environment["DOTBOT_STATE_ROOT"] = $global:DotbotProjectRoot + } $proc = [System.Diagnostics.Process]::new() $proc.StartInfo = $psi diff --git a/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 b/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 index 93a9053a..db57b676 100644 --- a/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 +++ b/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 @@ -106,6 +106,9 @@ function Write-ProcessFile { [string]$BotRoot ) $processesDir = Get-ProcessesDir -BotRoot $BotRoot + if (-not (Test-Path $processesDir)) { + New-Item -Path $processesDir -ItemType Directory -Force | Out-Null + } $filePath = Join-Path $processesDir "$Id.json" $tempFile = "$filePath.tmp" $retry = Get-ProcessRetryConfig -BotRoot $BotRoot @@ -623,6 +626,51 @@ function Get-NextWorkflowTask { } return $true } + function _HasOutstandingGeneratedChildren { param($Task, $All) + # True when any non-terminal task was generated/expanded by one of $Task's + # dependencies. Children stamp extensions.runner.generated_by = + # and provenance.expanded_by = "task:"; a barrier's dependencies + # are canonical parent ids, so this is a direct id match (with a slug + # fallback for name-based deps). Scope note: this is invoked only for + # type=barrier tasks and matches direct children (not nested descendants); + # widening is a one-line change at the call site. + $deps = @($Task.dependencies) | Where-Object { $_ } | ForEach-Object { [string]$_ } + if ($deps.Count -eq 0) { return $false } + $depSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($d in $deps) { + [void]$depSet.Add($d) + $slug = ($d -replace '[^a-zA-Z0-9\s-]','' -replace '\s+','-').ToLowerInvariant() + if ($slug) { [void]$depSet.Add($slug) } + } + foreach ($t in $All) { + $c = $t.Content + # "Complete" uses the SAME rule as the dependency done-set above: a + # skipped child counts as complete only when its skip reason is + # intentional. A framework-error skip (max-retries / non-recoverable) + # is NOT complete — Reset-SkippedTasks retries it — so the barrier must + # keep waiting rather than fire over failed/​retried work. needs-input / + # needs-review children are likewise outstanding until resolved. + $childComplete = switch ([string]$c.status) { + 'done' { $true } + 'cancelled' { $true } + 'split' { $true } + 'skipped' { $r = Get-DotbotTaskSkipReason -TaskContent $c; [bool]($r -and $intentionalSkips -contains $r) } + default { $false } + } + if ($childComplete) { continue } + $gen = Get-DotbotTaskNestedProp -Object $c -Path @('extensions','runner','generated_by') + $exp = Get-DotbotTaskNestedProp -Object $c -Path @('provenance','expanded_by') + if ($exp -and "$exp" -match '^task:(.+)$') { $exp = $Matches[1] } + foreach ($ref in @($gen, $exp)) { + if (-not $ref) { continue } + $refStr = [string]$ref + if ($depSet.Contains($refStr)) { return $true } + $refSlug = ($refStr -replace '[^a-zA-Z0-9\s-]','' -replace '\s+','-').ToLowerInvariant() + if ($refSlug -and $depSet.Contains($refSlug)) { return $true } + } + } + return $false + } function _IsTaskIgnored { param($Task) if ($Task.PSObject.Properties['ignore'] -and $Task.ignore -and $Task.ignore.PSObject.Properties['manual'] -and $Task.ignore.manual -eq $true) { @@ -662,7 +710,18 @@ function Get-NextWorkflowTask { foreach ($cand in $candidates) { $c = $cand.Content if (_IsTaskIgnored -Task $c) { continue } + # Dependencies gate the manifest condition. Evaluating the condition first + # (as this loop used to) permanently skipped a task whose condition points + # at a file its own upstream dependency produces — the file does not exist + # yet at launch, so the task was marked condition-not-met before the + # producer ran, and that skip cascaded (condition-not-met satisfies + # dependents). Checking deps first keeps such a task 'todo' (blocked); the + # condition is re-evaluated on a later selection pass once the producer has + # completed (Get-NextWorkflowTask reloads task state from disk each call). + if (-not (_AreDepsMet -Task $c -Set $doneSet)) { $blockedCount++; continue } if (-not (_IsManifestConditionMet -Task $c)) { + # Deps are satisfied but the condition is genuinely unmet, so the + # producing task has already had its turn — a real condition-skip. try { _MarkConditionNotMet -Candidate $cand } catch { @@ -673,12 +732,21 @@ function Get-NextWorkflowTask { } continue } - if (-not (_AreDepsMet -Task $c -Set $doneSet)) { $blockedCount++; continue } + # A barrier must not fire while work its dependencies spawned into + # tasks/todo is still outstanding. Its explicit deps (the generator) go + # done the instant generation finishes, but the spawned children run after. + if (([string]$c.type -eq 'barrier') -and (_HasOutstandingGeneratedChildren -Task $c -All $allTasks)) { + if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) { + Write-BotLog -Level Debug -Message "Barrier '$($c.name)' blocked: generated children of its dependencies are not yet complete." + } + $blockedCount++ + continue + } $eligible += $cand } if ($eligible.Count -gt 0) { $next = $eligible | Sort-Object @( - @{ Expression = { if ($_.Content.priority -is [int] -or $_.Content.priority -is [long]) { -[int]$_.Content.priority } else { 0 } }; Ascending = $true } + @{ Expression = { if ($_.Content.priority -is [int] -or $_.Content.priority -is [long]) { [int]$_.Content.priority } else { [int]::MaxValue } }; Ascending = $true } @{ Expression = { [string]$_.Content.created_at }; Ascending = $true } ) | Select-Object -First 1 return @{ diff --git a/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 b/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 index a6bdc918..8ced422d 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 +++ b/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 @@ -42,6 +42,13 @@ 'Write-ActivityEvent' 'Get-ActivityLogPath' 'Get-DotbotProjectId' + 'Get-ActivityLogEventTypes' + + # Event bus (publish side) + 'Publish-DotBotEvent' + 'Register-DotBotEventType' + 'Get-DotBotEventTypeRegistry' + 'Test-DotBotEventTypeRegistered' # Control plane 'Get-ControlPlaneSettings' diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 index 16fd256a..30461178 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 @@ -8,11 +8,14 @@ line — the runtime is the sole writer, and a per-process SemaphoreSlim guards the writer so two listener threads on the same runtime can't interleave bytes. -Event shape: +Event shape (dotted type so each line is a bus event matching task.* / +workflow.* sink globs): { + "id": "evt_xxxxxxxx", + "type": "task.created" | "task.status_changed" | "workflow.run_started" | ... "timestamp": "2026-05-18T10:00:00Z", + "source": "runtime", "project_id": "p_AbCd1234", - "type": "task_created" | "task_status_changed" | ... "task_id": "t_xxxxxxxx", // when relevant "run_id": "wr_xxxxxxxx", // when relevant "from": "in-progress", // on transitions @@ -43,15 +46,95 @@ function _Get-ActivityLogLock { } return $lock } + +# --------------------------------------------------------------------------- +# Event-type registry (extensible) +# +# Publish-DotBotEvent validates a dotted event type (e.g. 'task.completed') +# against this registry. Entries are wildcard patterns, so a family like +# 'task.*' registers a whole namespace. An unregistered type is LOGGED and +# STILL DELIVERED — never rejected — so new families (e.g. 'nudge.*') need no +# publisher change; they just append to the registry when their producer loads. +# +# Stored on the AppDomain (like the writer lock) so a registration made in one +# runspace is visible to publishers in the per-request runspaces. +# --------------------------------------------------------------------------- +$script:DotbotEventTypeRegistryKey = 'Dotbot.Runtime.EventTypeRegistry' +$script:DotbotDefaultEventTypes = @( + 'task.*' + 'workflow.*' +) + +function _Get-EventTypeRegistry { + $registry = [System.AppDomain]::CurrentDomain.GetData($script:DotbotEventTypeRegistryKey) + if ($null -eq $registry) { + $registry = [System.Collections.Generic.List[string]]::new() + foreach ($t in $script:DotbotDefaultEventTypes) { $registry.Add($t) } + [System.AppDomain]::CurrentDomain.SetData($script:DotbotEventTypeRegistryKey, $registry) + } + # Unary comma prevents PowerShell from unrolling the List on return, so + # callers get the live List object (needed for .Add / .ToArray), not its + # enumerated elements. + return ,$registry +} + +function Register-DotBotEventType { + <# + .SYNOPSIS + Register an event type (or a wildcard family such as 'nudge.*') so that + Publish-DotBotEvent treats it as a known type. + + .DESCRIPTION + Idempotent: registering the same pattern twice is a no-op. Registration is + process-wide (AppDomain-backed) so producers loaded in any runspace share + one registry. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$Type) + + $registry = _Get-EventTypeRegistry + if (-not ($registry -contains $Type)) { + $registry.Add($Type) + } +} + +function Get-DotBotEventTypeRegistry { + <# + .SYNOPSIS + Return the current event-type registry (array of patterns). For tests and + diagnostics. + #> + return ,@((_Get-EventTypeRegistry).ToArray()) +} + +function Test-DotBotEventTypeRegistered { + <# + .SYNOPSIS + Return $true when the given concrete type matches any registered pattern. + + .DESCRIPTION + Registry entries are treated as wildcard patterns, so 'task.completed' + matches the registered family 'task.*', and an exactly-registered concrete + type matches itself. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$Type) + + foreach ($pattern in (_Get-EventTypeRegistry)) { + if ($Type -like $pattern) { return $true } + } + return $false +} + $script:DotbotActivityLogEventTypes = @( - 'task_created' - 'task_updated' - 'task_status_changed' - 'workflow_run_started' - 'workflow_run_completed' - 'workflow_run_failed' - 'workflow_run_cancelled' - 'hook_failed' + 'task.created' + 'task.updated' + 'task.status_changed' + 'workflow.run_started' + 'workflow.run_completed' + 'workflow.run_failed' + 'workflow.run_cancelled' + 'hook.failed' ) function Get-ActivityLogPath { @@ -124,6 +207,114 @@ function Get-ActivityLogEventTypes { return ,@($script:DotbotActivityLogEventTypes) } +function _New-DotBotEventId { + # Reuse the runtime's nanoid generator (imported globally via Dotbot.Task) + # so event ids share the house 'prefix_ + 8 chars' convention (t_, wr_, p_). + if (-not (Get-Command New-DotbotNanoId -ErrorAction SilentlyContinue)) { + throw "Publish-DotBotEvent requires New-DotbotNanoId (Dotbot.Task IdGen) — module not loaded." + } + return 'evt_' + (New-DotbotNanoId) +} + +function _Append-ActivityLogLine { + <# + .SYNOPSIS + Append one already-serialized JSON line to /.control/activity.jsonl + under the process-wide writer lock. Shared by Write-ActivityEvent and + Publish-DotBotEvent so both use the SAME SemaphoreSlim and can never + interleave bytes with each other. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + [Parameter(Mandatory)] [string]$Line + ) + + $path = Get-ActivityLogPath -BotRoot $BotRoot + $dir = Split-Path -Parent $path + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + + $lock = _Get-ActivityLogLock + $lock.Wait() + try { + # AppendAllText opens / appends / closes per call. On POSIX this is + # atomic for sub-PIPE_BUF writes (any line we produce here). On NTFS + # it's atomic for sub-4KB writes. The +newline keeps lines separated. + [System.IO.File]::AppendAllText( + $path, + $Line + [System.Environment]::NewLine, + [System.Text.UTF8Encoding]::new($false) + ) + } finally { + [void]$lock.Release() + } +} + +function Publish-DotBotEvent { + <# + .SYNOPSIS + Publish a typed event onto the event bus. + + .DESCRIPTION + Stamps a well-formed envelope — id, type, timestamp, source, data, plus the + project id and actor — and appends it as one JSON line to + /.control/activity.jsonl. Publishing to the activity log means the + /api/activity/tail byte-cursor endpoint carries bus events to the browser + with no new transport. + + The Type is validated against the extensible event-type registry. An + UNREGISTERED type is logged and STILL DELIVERED (never rejected), so new + event families need no change to this publisher. + + .PARAMETER Type + The dotted event type, e.g. 'task.completed' or 'workflow.run_failed'. + + .PARAMETER Source + Where the event originated, e.g. 'runtime'. + + .PARAMETER Data + Arbitrary event payload; serialized as the envelope's nested 'data' object. + + .OUTPUTS + The envelope hashtable that was written (so callers/tests can inspect the id). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + [Parameter(Mandatory)] [string]$Type, + [Parameter(Mandatory)] [string]$Source, + [hashtable]$Data, + [string]$Actor = 'system' + ) + + if (-not (Test-DotBotEventTypeRegistered -Type $Type)) { + # Logged, not rejected — the registry is extensible by design. Debug + # level: Write-BotLog only mirrors Info+ into activity.jsonl, so this + # note never pollutes the bus itself. Guarded so the module stays + # usable when Dotbot.Logging isn't loaded. + if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) { + Write-BotLog -Level Debug -Message "Publish-DotBotEvent: unregistered event type '$Type' — delivering anyway." + } + } + + $envelope = [ordered]@{ + id = _New-DotBotEventId + type = $Type + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + source = $Source + data = if ($null -ne $Data) { $Data } else { @{} } + project_id = Get-DotbotProjectId -BotRoot $BotRoot + actor = $Actor + } + + $line = $envelope | ConvertTo-Json -Depth 10 -Compress + _Append-ActivityLogLine -BotRoot $BotRoot -Line $line + + return $envelope +} + function Write-ActivityEvent { <# .SYNOPSIS @@ -155,13 +346,16 @@ function Write-ActivityEvent { [string]$From, [string]$To, [string]$Actor = 'system', - [string]$Reason + [string]$Reason, + [string]$Source = 'runtime' ) $event = [ordered]@{ + id = _New-DotBotEventId + type = $Type timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + source = $Source project_id = Get-DotbotProjectId -BotRoot $BotRoot - type = $Type } if ($TaskId) { $event['task_id'] = $TaskId } if ($RunId) { $event['run_id'] = $RunId } @@ -174,31 +368,15 @@ function Write-ActivityEvent { # one entry = one physical line, which is what the UI's FileWatcher # consumer assumes. $line = $event | ConvertTo-Json -Depth 6 -Compress - - $path = Get-ActivityLogPath -BotRoot $BotRoot - $dir = Split-Path -Parent $path - if (-not (Test-Path -LiteralPath $dir)) { - New-Item -ItemType Directory -Path $dir -Force | Out-Null - } - - $lock = _Get-ActivityLogLock - $lock.Wait() - try { - # AppendAllText opens / appends / closes per call. On POSIX this is - # atomic for sub-PIPE_BUF writes (any line we produce here). On NTFS - # it's atomic for sub-4KB writes. The +newline keeps lines separated. - [System.IO.File]::AppendAllText( - $path, - $line + [System.Environment]::NewLine, - [System.Text.UTF8Encoding]::new($false) - ) - } finally { - [void]$lock.Release() - } + _Append-ActivityLogLine -BotRoot $BotRoot -Line $line } Export-ModuleMember -Function @( 'Write-ActivityEvent' + 'Publish-DotBotEvent' + 'Register-DotBotEventType' + 'Get-DotBotEventTypeRegistry' + 'Test-DotBotEventTypeRegistered' 'Get-ActivityLogPath' 'Get-DotbotProjectId' 'Get-ActivityLogEventTypes' diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 index 60314571..dc2b2a07 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 @@ -1034,7 +1034,7 @@ function Invoke-CreateTaskHandler { Lock-TaskMutex -TaskId $task.id | Out-Null try { _Write-TaskFileAtomic -Path $filePath -Content $task - Write-ActivityEvent -BotRoot $BotRoot -Type 'task_created' -TaskId $task.id -Actor $actor + Write-ActivityEvent -BotRoot $BotRoot -Type 'task.created' -TaskId $task.id -Actor $actor } finally { Unlock-TaskMutex -TaskId $task.id } @@ -1139,7 +1139,7 @@ function Invoke-PatchTaskHandler { } _Write-TaskFileAtomic -Path $path -Content $task - Write-ActivityEvent -BotRoot $BotRoot -Type 'task_updated' -TaskId $task.id -Actor $actor + Write-ActivityEvent -BotRoot $BotRoot -Type 'task.updated' -TaskId $task.id -Actor $actor } finally { Unlock-TaskMutex -TaskId $taskId } @@ -1232,6 +1232,14 @@ function Invoke-TaskStatusHandler { } } + # A transition that is about to proceed resolves any prior hook-blocked-done + # breadcrumb (set on the abort-revert path below). Clearing it here means a + # stale marker never lingers once the task makes forward progress. + if ($task['extensions'] -and ($task['extensions']['runner'] -is [System.Collections.IDictionary]) -and + $task['extensions']['runner'].ContainsKey('done_transition_block')) { + [void]$task['extensions']['runner'].Remove('done_transition_block') + } + try { Assert-TaskInstance -Task $task } catch { @@ -1242,7 +1250,7 @@ function Invoke-TaskStatusHandler { # Write the new status FIRST so hooks observe a consistent on-disk # view. _Write-TaskFileAtomic -Path $path -Content $task - Write-ActivityEvent -BotRoot $BotRoot -Type 'task_status_changed' -TaskId $task.id -From $from -To $to -Actor $actor -Reason $reason + Write-ActivityEvent -BotRoot $BotRoot -Type 'task.status_changed' -TaskId $task.id -From $from -To $to -Actor $actor -Reason $reason # Transition-hook dispatch. Hooks run synchronously, inline with # this handler, inside the task mutex. A failing hook with @@ -1287,11 +1295,29 @@ function Invoke-TaskStatusHandler { } else { $task['completed_at'] = $null } + # Breadcrumb the task-runner reads to escalate a hook-blocked done to + # needs-input instead of skipped(max-retries). Under extensions.runner + # (schema rejects unknown top-level fields); cleared on next success. + if ($to -eq 'done') { + # Type-guard both levels (extensions content is not schema-validated): + # a non-dict extensions/runner would make the index-assign below throw. + if ($task['extensions'] -isnot [System.Collections.IDictionary]) { $task['extensions'] = @{} } + if ($task['extensions']['runner'] -isnot [System.Collections.IDictionary]) { $task['extensions']['runner'] = @{} } + # Truncate the persisted message — hook output is unbounded; the full + # text stays in the 422 response + activity log (matches AuthError cap). + $failingMsg = [string]$hookResult.failing_message + if ($failingMsg.Length -gt 1000) { $failingMsg = $failingMsg.Substring(0, 1000) + " … [truncated, showing 1000 of $($failingMsg.Length) chars]" } + $task['extensions']['runner']['done_transition_block'] = @{ + hook = [string]$hookResult.failing_hook + message = $failingMsg + at = $task['updated_at'] + } + } _Write-TaskFileAtomic -Path $path -Content $task Write-ActivityEvent ` -BotRoot $BotRoot ` - -Type 'hook_failed' ` + -Type 'hook.failed' ` -TaskId $task.id ` -From $to ` -To $from ` @@ -1319,7 +1345,7 @@ function Invoke-TaskStatusHandler { # response is honest about the half-finished state. Write-ActivityEvent ` -BotRoot $BotRoot ` - -Type 'hook_failed' ` + -Type 'hook.failed' ` -TaskId $task.id ` -To $to ` -Actor $actor ` @@ -1507,7 +1533,7 @@ function Invoke-CreateRunHandler { _Write-TaskFileAtomic -Path $layout.run_record_path -Content $record _Write-TaskFileAtomic -Path $layout.live_status_path -Content $status - Write-ActivityEvent -BotRoot $BotRoot -Type 'workflow_run_started' -RunId $runId -Actor $startedBy + Write-ActivityEvent -BotRoot $BotRoot -Type 'workflow.run_started' -RunId $runId -Actor $startedBy } finally { Unlock-RunMutex -RunId $runId } diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/Imports.ps1 b/src/runtime/Modules/Dotbot.Runtime/Private/Imports.ps1 index d79a906c..3ca95b57 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/Imports.ps1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/Imports.ps1 @@ -8,3 +8,4 @@ Import-Module (Join-Path $runtimeModules 'Dotbot.Process' 'Dotbot.Process.psd1') Import-Module (Join-Path $runtimeModules 'Dotbot.Hook' 'Dotbot.Hook.psd1') -DisableNameChecking -Global Import-Module (Join-Path $runtimeModules 'Dotbot.Settings' 'Dotbot.Settings.psd1') -DisableNameChecking -Global Import-Module (Join-Path $runtimeModules 'Dotbot.Handoff' 'Dotbot.Handoff.psd1') -DisableNameChecking -Global +Import-Module (Join-Path $runtimeModules 'Dotbot.Events' 'Dotbot.Events.psd1') -DisableNameChecking -Global diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 index 9d5556a2..4d6ed151 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 @@ -204,6 +204,7 @@ function Start-DotbotRuntime { attached = $true listener = $null control_plane = $null + events_consumer = $null } } @@ -242,6 +243,7 @@ function Start-DotbotRuntime { attached = $false listener = $listener control_plane = $null + events_consumer = $null } $runtimeRegistration = @{ @@ -257,6 +259,16 @@ function Start-DotbotRuntime { $result.control_plane = [ordered]@{ enabled = $true; registered = $false; error = $_.Exception.Message } } + # Event-bus consumer — hosted here so there is exactly one per project in + # every mode (both `dotbot go` and `dotbot serve` launch the runtime + # server; the UI server never hosts it). Start-EventConsumer returns $null + # when the bus is disabled. + try { + $result.events_consumer = Start-EventConsumer -BotRoot $BotRoot + } catch { + $result.events_consumer = [ordered]@{ enabled = $true; started = $false; error = $_.Exception.Message } + } + if ($Foreground) { # The listener loop runs on a background ThreadPool job. In foreground # mode we block in this caller until someone signals shutdown — that @@ -268,7 +280,7 @@ function Start-DotbotRuntime { Start-Sleep -Milliseconds 250 } } finally { - Stop-DotbotRuntime -BotRoot $BotRoot -Listener $listener -ControlPlaneRegistration $result.control_plane -ErrorAction SilentlyContinue + Stop-DotbotRuntime -BotRoot $BotRoot -Listener $listener -ControlPlaneRegistration $result.control_plane -EventConsumer $result.events_consumer -ErrorAction SilentlyContinue } } @@ -290,7 +302,8 @@ function Stop-DotbotRuntime { param( [Parameter(Mandatory)] [string]$BotRoot, [System.Net.HttpListener]$Listener, - [object]$ControlPlaneRegistration + [object]$ControlPlaneRegistration, + [object]$EventConsumer ) if ($Listener) { @@ -305,6 +318,10 @@ function Stop-DotbotRuntime { try { Stop-ControlPlaneRegistration -BotRoot $BotRoot -Registration $ControlPlaneRegistration } catch { $null = $_ } } + if ($EventConsumer) { + try { Stop-EventConsumer -Consumer $EventConsumer } catch { $null = $_ } + } + Remove-RuntimeConnectionFile -BotRoot $BotRoot Clear-RuntimeMutexPool } diff --git a/src/runtime/Modules/Dotbot.Task/Dotbot.Task.psm1 b/src/runtime/Modules/Dotbot.Task/Dotbot.Task.psm1 index bca86c86..763cda7f 100644 --- a/src/runtime/Modules/Dotbot.Task/Dotbot.Task.psm1 +++ b/src/runtime/Modules/Dotbot.Task/Dotbot.Task.psm1 @@ -1022,6 +1022,25 @@ function New-MergeFailurePendingQuestion { asked_at = $askedAt } } + 'unrelated_history' { + # Two task worktrees were both cut before the project had any commits, + # so they are orphan roots with no common ancestor. Both added the same + # file(s) with different content — there is no base to auto-merge + # against, so the operator must choose which version wins. + $conflictDetail = if ($ConflictFiles.Count -gt 0) { $ConflictFiles -join '; ' } else { '(none reported)' } + return @{ + id = "merge-unrelated-history" + question = "Unrelated-history merge conflict — this task predates the project's first commit" + context = "This task's worktree was created before the project had any commits, so it shares no history with main. It and an earlier task both produced these file(s): $conflictDetail. There is no common ancestor to auto-merge, so choose which version to keep. Worktree preserved at: $WorktreePath" + options = @( + @{ key = "A"; label = "Open the worktree and merge manually (recommended)"; rationale = "Inspect both versions at $WorktreePath, reconcile the file(s), then retry merge" } + @{ key = "B"; label = "Keep this task's version"; rationale = "Overwrite main's copy of the conflicting file(s) with this task's version" } + @{ key = "C"; label = "Keep main's version (discard this task's changes to those files)"; rationale = "Abandon this task's copy of the conflicting file(s) and keep what is already on main" } + ) + recommendation = "A" + asked_at = $askedAt + } + } 'branch_missing' { return @{ id = "branch-missing" diff --git a/src/runtime/Modules/Dotbot.TaskInput/Dotbot.TaskInput.psm1 b/src/runtime/Modules/Dotbot.TaskInput/Dotbot.TaskInput.psm1 index 52e9aa70..da6facc1 100644 --- a/src/runtime/Modules/Dotbot.TaskInput/Dotbot.TaskInput.psm1 +++ b/src/runtime/Modules/Dotbot.TaskInput/Dotbot.TaskInput.psm1 @@ -333,57 +333,6 @@ function Resolve-TaskInputAnswer { } } -function Get-TaskInputProductDir { - param( - [Parameter(Mandatory)][string]$BotRoot, - [string]$TaskId - ) - - if ($TaskId -and (Get-Command Get-TaskWorktreeInfo -ErrorAction SilentlyContinue)) { - try { - $worktreeInfo = Get-TaskWorktreeInfo -TaskId $TaskId -BotRoot $BotRoot - $worktreePath = if ($worktreeInfo -and $worktreeInfo.PSObject.Properties['worktree_path']) { [string]$worktreeInfo.worktree_path } else { $null } - if ($worktreePath -and (Test-Path -LiteralPath $worktreePath)) { - $worktreeProductDir = Join-Path $worktreePath ".bot/workspace/product" - if (Test-Path -LiteralPath $worktreeProductDir) { - return $worktreeProductDir - } - } - } catch { - if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) { - Write-BotLog -Level Debug -Message "Could not resolve task worktree product dir" -Exception $_ - } - } - } - - return (Join-Path (Join-Path $BotRoot "workspace") "product") -} - -function Write-TaskInputInterviewAnswer { - param( - [string]$BotRoot, - [string]$TaskId, - [hashtable]$Entry - ) - - $productDir = Get-TaskInputProductDir -BotRoot $BotRoot -TaskId $TaskId - if (-not (Test-Path -LiteralPath $productDir)) { return } - - $answersPath = Join-Path $productDir "interview-answers.json" - $existing = @() - if (Test-Path -LiteralPath $answersPath) { - try { - $existing = @((Get-Content -LiteralPath $answersPath -Raw | ConvertFrom-Json).answers) - } catch { - $existing = @() - } - } - - $existing = @($existing | Where-Object { $_.question_id -ne $Entry.question_id }) - $existing += [pscustomobject]$Entry - @{ answers = $existing } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $answersPath -Encoding UTF8NoBOM -} - function Get-TaskInputTargetPath { param( [Parameter(Mandatory)] [System.IO.FileInfo]$TaskFile, @@ -457,10 +406,19 @@ function Add-TaskInputResolvedQuestion { [array]$ReviewedAttachmentIds ) + # questions_resolved is the canonical, task-owned record of every answered + # question (issue #516). It carries the full answer detail the start-from-prompt + # workflow needs — context plus the structured option key/label — so that + # workflow can reread answers from task state instead of a shared product-dir + # file. Per-task state travels with the task and never collides across parallel + # worktrees, so no runtime lock/exclude/merge is required. $entry = @{ id = Get-TaskInputProp -Object $Question -Name 'id' question = Get-TaskInputProp -Object $Question -Name 'question' + context = Get-TaskInputProp -Object $Question -Name 'context' answer = $Resolved.answer + answer_key = $Resolved.answer_key + answer_label = $Resolved.answer_label answer_type = $Resolved.answer_type asked_at = Get-TaskInputProp -Object $Question -Name 'asked_at' answered_at = $AnsweredAt @@ -526,16 +484,6 @@ function Invoke-TaskQuestionAnswerTransition { $resolved = Resolve-TaskInputAnswer -Question $targetQuestion -Answer $Answer $now = Get-TaskInputTimestamp Add-TaskInputResolvedQuestion -RunnerBag $runner -Question $targetQuestion -Resolved $resolved -AnsweredAt $now -AnsweredVia $AnsweredVia -Attachments $Attachments -Comment $Comment -RankedItems $RankedItems -ReviewedAttachmentIds $ReviewedAttachmentIds | Out-Null - Write-TaskInputInterviewAnswer -BotRoot $BotRoot -TaskId $taskId -Entry @{ - task_id = $taskId - question_id = Get-TaskInputProp -Object $targetQuestion -Name 'id' - question = Get-TaskInputProp -Object $targetQuestion -Name 'question' - context = Get-TaskInputProp -Object $targetQuestion -Name 'context' - answer_key = $resolved.answer_key - answer_label = $resolved.answer_label - answer = $resolved.answer - answered_at = $now - } $targetQuestionId = Get-TaskInputProp -Object $targetQuestion -Name 'id' $remaining = @($pendingQuestions | Where-Object { (Get-TaskInputProp -Object $_ -Name 'id') -ne $targetQuestionId }) @@ -609,16 +557,6 @@ function Invoke-TaskQuestionAnswerTransition { $resolvedSingle = Resolve-TaskInputAnswer -Question $pendingQuestion -Answer $Answer $nowSingle = Get-TaskInputTimestamp Add-TaskInputResolvedQuestion -RunnerBag $runner -Question $pendingQuestion -Resolved $resolvedSingle -AnsweredAt $nowSingle -AnsweredVia $AnsweredVia -Attachments $Attachments -Comment $Comment -RankedItems $RankedItems -ReviewedAttachmentIds $ReviewedAttachmentIds | Out-Null - Write-TaskInputInterviewAnswer -BotRoot $BotRoot -TaskId $taskId -Entry @{ - task_id = $taskId - question_id = Get-TaskInputProp -Object $pendingQuestion -Name 'id' - question = Get-TaskInputProp -Object $pendingQuestion -Name 'question' - context = Get-TaskInputProp -Object $pendingQuestion -Name 'context' - answer_key = $resolvedSingle.answer_key - answer_label = $resolvedSingle.answer_label - answer = $resolvedSingle.answer - answered_at = $nowSingle - } Set-TaskInputProp -Object $runner -Name 'pending_question' -Value $null Remove-TaskInputProp -Object $runner -Name 'notification' diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index 71fdb24e..bd5f9f8a 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -588,7 +588,11 @@ function Set-DotbotMcpServerJson { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$FrameworkRoot, - [Parameter(Mandatory)][string]$WorktreePath + [Parameter(Mandatory)][string]$WorktreePath, + # Stable main repo root for runtime/task-state resolution (#515). The + # agent's cwd stays the worktree (DOTBOT_PROJECT_ROOT); state resolution + # follows DOTBOT_STATE_ROOT so it never relies on the worktree junction. + [string]$StateRoot ) $mcpConfig = [pscustomobject]@{ mcpServers = [pscustomobject]@{} } @@ -612,6 +616,7 @@ function Set-DotbotMcpServerJson { DOTBOT_PROJECT_ROOT = $WorktreePath } } + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { $server.env['DOTBOT_STATE_ROOT'] = $StateRoot } $mcpConfig.mcpServers | Add-Member -NotePropertyName dotbot -NotePropertyValue $server -Force $dir = Split-Path $Path -Parent @@ -623,10 +628,18 @@ function Set-DotbotCodexMcpConfig { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$FrameworkRoot, - [Parameter(Mandatory)][string]$WorktreePath + [Parameter(Mandatory)][string]$WorktreePath, + [string]$StateRoot ) $mcpScript = Join-Path $FrameworkRoot 'src/mcp/dotbot-mcp.ps1' + $envLines = @( + ('DOTBOT_HOME = {0}' -f (ConvertTo-DotbotTomlString $FrameworkRoot)) + ('DOTBOT_PROJECT_ROOT = {0}' -f (ConvertTo-DotbotTomlString $WorktreePath)) + ) + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { + $envLines += ('DOTBOT_STATE_ROOT = {0}' -f (ConvertTo-DotbotTomlString $StateRoot)) + } $lines = @( '# Generated by dotbot for this execution worktree.' '[mcp_servers.dotbot]' @@ -634,10 +647,7 @@ function Set-DotbotCodexMcpConfig { ('args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", {0}]' -f (ConvertTo-DotbotTomlString $mcpScript)) '' '[mcp_servers.dotbot.env]' - ('DOTBOT_HOME = {0}' -f (ConvertTo-DotbotTomlString $FrameworkRoot)) - ('DOTBOT_PROJECT_ROOT = {0}' -f (ConvertTo-DotbotTomlString $WorktreePath)) - '' - ) + ) + $envLines + @('') $dir = Split-Path $Path -Parent if (-not (Test-Path -LiteralPath $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } Set-Content -Path $Path -Value ($lines -join "`n") -Encoding utf8NoBOM @@ -647,7 +657,8 @@ function Set-DotbotAntigravityMcpConfig { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$FrameworkRoot, - [Parameter(Mandatory)][string]$WorktreePath + [Parameter(Mandatory)][string]$WorktreePath, + [string]$StateRoot ) $settings = [pscustomobject]@{ mcpServers = [pscustomobject]@{} } @@ -671,6 +682,7 @@ function Set-DotbotAntigravityMcpConfig { DOTBOT_PROJECT_ROOT = $WorktreePath } } + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { $server.env['DOTBOT_STATE_ROOT'] = $StateRoot } $settings.mcpServers | Add-Member -NotePropertyName dotbot -NotePropertyValue $server -Force $dir = Split-Path $Path -Parent @@ -682,7 +694,8 @@ function Set-DotbotOpenCodeMcpConfig { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$FrameworkRoot, - [Parameter(Mandatory)][string]$WorktreePath + [Parameter(Mandatory)][string]$WorktreePath, + [string]$StateRoot ) $config = [pscustomobject]@{ @@ -713,6 +726,7 @@ function Set-DotbotOpenCodeMcpConfig { DOTBOT_PROJECT_ROOT = $WorktreePath } } + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { $server.environment['DOTBOT_STATE_ROOT'] = $StateRoot } $config.mcp | Add-Member -NotePropertyName dotbot -NotePropertyValue $server -Force $dir = Split-Path $Path -Parent @@ -838,10 +852,10 @@ function Initialize-DotbotWorktreeExecutionEnvironment { Copy-DotbotDirectoryContents -Source (Join-Path $BotRoot 'settings') -Destination (Join-Path $worktreeBotRoot 'settings') Copy-DotbotProviderContent -WorktreePath $WorktreePath -BotRoot $BotRoot -FrameworkRoot $frameworkRoot - Set-DotbotMcpServerJson -Path (Join-Path $WorktreePath '.mcp.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath - Set-DotbotCodexMcpConfig -Path (Join-Path $WorktreePath '.codex/config.toml') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath - Set-DotbotAntigravityMcpConfig -Path (Join-Path $WorktreePath '.agents/mcp_config.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath - Set-DotbotOpenCodeMcpConfig -Path (Join-Path $WorktreePath '.opencode/opencode.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath + Set-DotbotMcpServerJson -Path (Join-Path $WorktreePath '.mcp.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot + Set-DotbotCodexMcpConfig -Path (Join-Path $WorktreePath '.codex/config.toml') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot + Set-DotbotAntigravityMcpConfig -Path (Join-Path $WorktreePath '.agents/mcp_config.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot + Set-DotbotOpenCodeMcpConfig -Path (Join-Path $WorktreePath '.opencode/opencode.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot } function Test-JunctionsExist { @@ -1238,6 +1252,11 @@ function Apply-TaskBranchPatch { [Parameter(Mandatory)][string]$BranchName ) + # When two task branches were both cut while the project had no commits, they + # are unrelated orphan roots with no merge-base. Track that so a resulting + # conflict can be reported as 'unrelated_history' (an operator-recoverable, + # no-common-ancestor collision) rather than the generic 'rebase_conflict'. + $unrelatedHistory = $false $mergeBase = (git -C $ProjectRoot merge-base $BaseBranch $BranchName 2>$null) if ($LASTEXITCODE -ne 0 -or -not $mergeBase) { git -C $ProjectRoot rev-parse --verify "$BranchName^{commit}" 2>$null | Out-Null @@ -1252,6 +1271,7 @@ function Apply-TaskBranchPatch { # task branches still have no merge-base; replay them as changes from # the empty tree so any task can be the first one completed. $mergeBase = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + $unrelatedHistory = $true } $patchPath = [System.IO.Path]::GetTempFileName() @@ -1343,11 +1363,18 @@ function Apply-TaskBranchPatch { Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue } + # An empty-tree (unrelated-history) base means the conflict is an + # add/add between two orphan branches with no common ancestor — there + # is no automatic correct merge, so surface it as its own kind so the + # operator gets an actionable choice instead of an opaque conflict. + $failureKind = if ($conflictFiles.Count -gt 0) { + if ($unrelatedHistory) { 'unrelated_history' } else { 'rebase_conflict' } + } else { $null } return @{ success = $false output = @($applyOutput | ForEach-Object { "$_" }) conflict_files = $conflictFiles - failure_kind = if ($conflictFiles.Count -gt 0) { 'rebase_conflict' } else { $null } + failure_kind = $failureKind } } @@ -1425,6 +1452,14 @@ function New-TaskWorktree { Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue # Also prune git's worktree list so it doesn't think it still exists git -C $ProjectRoot worktree prune 2>$null + if (Test-Path $worktreePath) { + return @{ + worktree_path = $worktreePath + branch_name = $branchName + success = $false + message = "Stale worktree directory could not be removed: $worktreePath" + } + } } } @@ -1569,12 +1604,17 @@ function Complete-TaskWorktree { .OUTPUTS Hashtable with: - success — $true on full merge+commit+cleanup, $false otherwise + success — $true when merge+commit succeeded. NOTE: $true does NOT + guarantee worktree cleanup succeeded — if the directory + could not be removed (e.g. Windows open handles), success + stays $true, failure_kind stays $null, and the cleanup + failure is reported only via 'message' and an Error log. merge_commit — SHA of the resulting commit (success only) or $null message — human-readable summary conflict_files — array of conflicting paths (only populated for failure_kind='rebase_conflict') - failure_kind — one of: $null (on success), 'rebase_conflict', + failure_kind — one of: $null (merge succeeded; see 'success' re: cleanup), + 'rebase_conflict', 'branch_missing', 'merge_command_failed', 'commit_failed', 'exception'. Drives the kind-specific pending_question built by Move-TaskToMergeFailureNeedsInput. @@ -1768,15 +1808,20 @@ function Complete-TaskWorktree { # conflict on " pending_question instead of a generic # "Squash-merge command failed". Falls back to merge_command_failed # for non-conflict apply failures (no patch context, etc.). - $applyConflicts = if ($mergeResult.PSObject.Properties['conflict_files']) { + # $mergeResult is a hashtable — its keys aren't surfaced via + # .PSObject.Properties[...], which silently dropped conflict_files for + # every merge failure (rebase_conflict and unrelated_history). Use key access. + $applyConflicts = if ($mergeResult -is [hashtable]) { + if ($mergeResult.ContainsKey('conflict_files')) { @($mergeResult['conflict_files'] | Where-Object { $_ }) } else { @() } + } elseif ($mergeResult -and $mergeResult.PSObject.Properties['conflict_files']) { @($mergeResult.conflict_files | Where-Object { $_ }) } else { @() } $applyKind = [string]$mergeResult.failure_kind $resolvedKind = if ($applyKind) { $applyKind } else { 'merge_command_failed' } - $resolvedMessage = if ($resolvedKind -eq 'rebase_conflict') { - "Merge conflict during squash-merge: $($applyConflicts -join ', ')" - } else { - "Task branch patch failed: $($mergeOutput -join ' ')" + $resolvedMessage = switch ($resolvedKind) { + 'rebase_conflict' { "Merge conflict during squash-merge: $($applyConflicts -join ', ')" } + 'unrelated_history' { "Unrelated-history merge conflict (task predates the project's first commit): $($applyConflicts -join ', ')" } + default { "Task branch patch failed: $($mergeOutput -join ' ')" } } return @{ success = $false @@ -1882,12 +1927,36 @@ function Complete-TaskWorktree { } git -C $ProjectRoot worktree remove $worktreePath 2>$null } - # Verify worktree is actually gone (Fix: silent removal failures) + # Fallback: direct filesystem delete when git worktree remove leaves the dir + # behind (e.g. Windows open handles). Gated on junctions being gone — on + # Windows Remove-Item -Recurse follows junctions and would delete link + # targets (shared task state, product workspace). If junctions survived, + # skip the delete; the entry is kept below for manual cleanup. + $worktreeParentDir = Join-Path (Split-Path $ProjectRoot -Parent) "worktrees" (Split-Path $ProjectRoot -Leaf) + $rmErr = $null + if ((Test-Path $worktreePath) -and $junctionsClean -and -not (Test-JunctionsExist -WorktreePath $worktreePath)) { + Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir + Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable rmErr + git -C $ProjectRoot worktree prune 2>$null + } + if (Test-Path $worktreePath) { - Write-BotLog -Level Warn -Message "Worktree removal incomplete — path still exists: $worktreePath. Will be retried on next startup." + # Keep map entry to preserve tracking. NOTE: Remove-OrphanWorktrees skips done-status tasks, + # so no automatic retry fires. Manual cleanup required: remove $worktreePath and the map entry. + $rmDetail = if ($rmErr) { " Last delete error: $(($rmErr | Select-Object -First 1).Exception.Message)" } else { "" } + Write-BotLog -Level Error -Message "Worktree removal incomplete — path still exists: $worktreePath. Map entry kept. Manual cleanup required (Remove-OrphanWorktrees will not retry done-status tasks).$rmDetail" + return @{ + success = $true + merge_commit = $mergeCommit + message = "Squash-merged to $baseBranch (worktree directory cleanup failed — manual removal required: $worktreePath)" + conflict_files = @() + failure_kind = $null + failure_detail = "" + push_result = $pushResult + } } - git -C $ProjectRoot branch -D $branchName 2>$null + git -C $ProjectRoot branch -D $branchName 2>$null # Remove from registry (locked read-modify-write to prevent concurrent entry loss) Invoke-WorktreeMapLocked -BotRoot $BotRoot -Action { $lockedMap = Read-WorktreeMap -BotRoot $BotRoot @@ -2146,6 +2215,7 @@ function Remove-OrphanWorktrees { $orphanIds = @($map.Keys | Where-Object { -not $activeIds.Contains($_) }) + $failedOrphanIds = [System.Collections.Generic.List[string]]::new() foreach ($taskId in $orphanIds) { $entry = $map[$taskId] $worktreePath = $entry.worktree_path @@ -2177,18 +2247,45 @@ function Remove-OrphanWorktrees { } git -C $ProjectRoot worktree remove $worktreePath 2>$null } - # Verify worktree is actually gone (Fix: silent removal failures) + + # Fallback: direct filesystem delete when git worktree remove leaves the dir + # behind (e.g. Windows open handles). Gated on junctions being gone — on + # Windows Remove-Item -Recurse follows junctions and would delete link + # targets (shared task state, product workspace). If junctions survived, + # skip the delete; the entry is kept below for next-startup retry. + $worktreeParentDir = Join-Path (Split-Path $ProjectRoot -Parent) "worktrees" (Split-Path $ProjectRoot -Leaf) + $rmErr = $null + if ($worktreePath -and (Test-Path $worktreePath) -and $junctionsClean -and -not (Test-JunctionsExist -WorktreePath $worktreePath)) { + try { + Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir + Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable rmErr + git -C $ProjectRoot worktree prune 2>$null + } catch { + # A bounds-check failure (e.g. a legacy/non-canonical map entry) must + # never abort the whole sweep or skip the map prune below — keep the + # entry and move on to the next orphan. + Write-BotLog -Level Error -Message "Orphan worktree cleanup error for $taskId ($worktreePath): $($_.Exception.Message). Entry kept in map." + $null = $failedOrphanIds.Add($taskId) + continue + } + } + if ($worktreePath -and (Test-Path $worktreePath)) { - Write-BotLog -Level Warn -Message "Orphan worktree removal incomplete — path still exists: $worktreePath" + # Directory survived all removal attempts — keep in map so next startup retries + $rmDetail = if ($rmErr) { " Last delete error: $(($rmErr | Select-Object -First 1).Exception.Message)" } else { "" } + Write-BotLog -Level Error -Message "Orphan worktree removal incomplete — path still exists: $worktreePath. Entry kept in map for next-startup retry.$rmDetail" + $null = $failedOrphanIds.Add($taskId) + } else { + git -C $ProjectRoot branch -D $branchName 2>$null } - git -C $ProjectRoot branch -D $branchName 2>$null } - if ($orphanIds.Count -gt 0) { + $removedOrphanIds = @($orphanIds | Where-Object { -not $failedOrphanIds.Contains($_) }) + if ($removedOrphanIds.Count -gt 0) { # Locked read-modify-write — prevents concurrent processes from losing map entries Invoke-WorktreeMapLocked -BotRoot $BotRoot -Action { $lockedMap = Read-WorktreeMap -BotRoot $BotRoot - foreach ($id in $orphanIds) { $lockedMap.Remove($id) } + foreach ($id in $removedOrphanIds) { $lockedMap.Remove($id) } Write-WorktreeMap -Map $lockedMap -BotRoot $BotRoot } } diff --git a/src/runtime/Plugins/Events/Sinks/mothership/metadata.json b/src/runtime/Plugins/Events/Sinks/mothership/metadata.json new file mode 100644 index 00000000..01b0d822 --- /dev/null +++ b/src/runtime/Plugins/Events/Sinks/mothership/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "mothership", + "description": "Forward selected events to the mothership fleet server. Gated on mothership + events.mothership settings and honours mothership.sync_events. Ships as a gated no-op until the server-side fleet events endpoint (#599/#544) exists.", + "subscribed_events": ["*"], + "max_duration": 10 +} diff --git a/src/runtime/Plugins/Events/Sinks/mothership/script.ps1 b/src/runtime/Plugins/Events/Sinks/mothership/script.ps1 new file mode 100644 index 00000000..d04f864b --- /dev/null +++ b/src/runtime/Plugins/Events/Sinks/mothership/script.ps1 @@ -0,0 +1,83 @@ +<# +.SYNOPSIS +mothership sink — forward selected bus events to the mothership fleet server. + +GATED NO-OP (by design, for now): the server-side fleet events endpoint +(POST /api/fleet/{instance_id}/events) is owned by #599/#544 and does not exist +yet. Until it lands this sink evaluates all its gates and then no-ops instead of +POSTing, so enabling it is safe. When the endpoint ships, the forward step wires +through Dotbot.Notification (the existing mothership client) — the decision +logic here (Test-MothershipShouldForward) does not change. + +Gates (all must pass to forward): + - mothership.enabled (the fleet connection is configured/on) + - events.mothership.enabled (the sink itself is turned on) + - mothership.server_url is set + - event type matches mothership.sync_events (glob list; empty → all) + +Config is handed in via $Context.Settings (full merged settings). +#> + +function Test-MothershipShouldForward { + <# + .SYNOPSIS + Decide whether an event should be forwarded to the mothership. Pure logic + (no network) so it is unit-testable. Returns @{ forward = $bool; reason = '...' }. + #> + param( + [Parameter(Mandatory)] $Event, + [Parameter(Mandatory)] $Settings + ) + + $ms = $null + $sink = $null + if ($null -ne $Settings) { + $ms = $Settings.mothership + if ($null -ne $Settings.events) { $sink = $Settings.events.mothership } + } + + $msEnabled = $false + try { $msEnabled = [bool]$ms.enabled } catch { $msEnabled = $false } + if (-not $msEnabled) { return @{ forward = $false; reason = 'mothership_disabled' } } + + $sinkEnabled = $false + try { $sinkEnabled = [bool]$sink.enabled } catch { $sinkEnabled = $false } + if (-not $sinkEnabled) { return @{ forward = $false; reason = 'sink_disabled' } } + + $serverUrl = '' + try { $serverUrl = [string]$ms.server_url } catch { $serverUrl = '' } + if ([string]::IsNullOrWhiteSpace($serverUrl)) { return @{ forward = $false; reason = 'no_server_url' } } + + $type = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } + $sync = @($ms.sync_events) + $matched = $sync.Count -eq 0 + foreach ($s in $sync) { + if ($type -like [string]$s) { $matched = $true; break } + } + if (-not $matched) { return @{ forward = $false; reason = 'not_in_sync_events' } } + + return @{ forward = $true; reason = 'ok' } +} + +function Invoke-Sink { + param($Event, $Context) + + $settings = if ($null -ne $Context) { $Context.Settings } else { $null } + if ($null -eq $settings) { return @{ Success = $true; Message = 'mothership: no settings' } } + + $decision = Test-MothershipShouldForward -Event $Event -Settings $settings + if (-not $decision.forward) { + return @{ Success = $true; Message = "mothership: skipped ($($decision.reason))" } + } + + # GATED NO-OP: the fleet events endpoint (#599/#544) does not exist yet. + # When it does, forward here via Dotbot.Notification. Until then this is a + # deliberate no-op — the gates above still run so the behaviour is correct + # the moment the endpoint ships. + return @{ Success = $true; Message = 'mothership: would forward (fleet events endpoint pending #599/#544)' } +} + +Export-ModuleMember -Function @( + 'Invoke-Sink' + 'Test-MothershipShouldForward' +) diff --git a/src/runtime/Plugins/Events/Sinks/webhooks/metadata.json b/src/runtime/Plugins/Events/Sinks/webhooks/metadata.json new file mode 100644 index 00000000..3a32f260 --- /dev/null +++ b/src/runtime/Plugins/Events/Sinks/webhooks/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "webhooks", + "description": "POST matching events to configured HTTPS endpoints, HMAC-signed per endpoint. HTTPS-only with SSRF URL validation; each endpoint honours its own event filter.", + "subscribed_events": ["*"], + "max_duration": 15 +} diff --git a/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 b/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 new file mode 100644 index 00000000..e9f9d495 --- /dev/null +++ b/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 @@ -0,0 +1,217 @@ +<# +.SYNOPSIS +webhooks sink — POST matching bus events to configured HTTPS endpoints. + +Config (from settings' events.webhooks section, handed in via $Context.Settings.events): + { + "enabled": true, + "endpoints": [ + { "url": "https://hooks.example.com/dotbot", "events": ["task.*"], "secret": "…" } + ] + } + +For each configured endpoint whose `events` filter matches the event type AND +whose URL passes HTTPS + SSRF validation, the event JSON is POSTed with an +HMAC-SHA256 signature (header X-DotBot-Signature: sha256=) derived from the +endpoint's `secret`. + +Helpers are exported alongside Invoke-Sink so they can be unit-tested without a +live endpoint (the actual POST is integration-only). +#> + +function Test-IpBlocked { + <# + .SYNOPSIS + $true when an IP is loopback / private / link-local / metadata / otherwise + not a safe public destination (SSRF guard). + #> + param([Parameter(Mandatory)] [System.Net.IPAddress]$IpAddress) + + $ip = $IpAddress + # Collapse IPv4-mapped IPv6 (::ffff:a.b.c.d) down to IPv4 for range checks. + if ($ip.IsIPv4MappedToIPv6) { $ip = $ip.MapToIPv4() } + + if ([System.Net.IPAddress]::IsLoopback($ip)) { return $true } + + if ($ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { + $b = $ip.GetAddressBytes() # 4 bytes + if ($b[0] -eq 0) { return $true } # 0.0.0.0/8 "this network" + if ($b[0] -eq 10) { return $true } # 10/8 private + if ($b[0] -eq 127) { return $true } # loopback + if ($b[0] -eq 169 -and $b[1] -eq 254) { return $true } # 169.254/16 link-local (incl. 169.254.169.254 metadata) + if ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) { return $true } # 172.16/12 private + if ($b[0] -eq 192 -and $b[1] -eq 168) { return $true } # 192.168/16 private + if ($b[0] -eq 100 -and $b[1] -ge 64 -and $b[1] -le 127) { return $true } # 100.64/10 CGNAT + if ($b[0] -ge 224) { return $true } # 224/4 multicast + 240/4 reserved + return $false + } + + if ($ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { + if ($ip.IsIPv6LinkLocal -or $ip.IsIPv6Multicast -or $ip.IsIPv6SiteLocal) { return $true } + if ($ip.Equals([System.Net.IPAddress]::IPv6Loopback)) { return $true } + if ($ip.Equals([System.Net.IPAddress]::IPv6Any)) { return $true } + # fc00::/7 unique-local + $bytes = $ip.GetAddressBytes() + if (($bytes[0] -band 0xFE) -eq 0xFC) { return $true } + return $false + } + + return $true # unknown address family → treat as unsafe +} + +function Test-WebhookUrlAllowed { + <# + .SYNOPSIS + Validate a webhook URL: HTTPS-only + SSRF guard. Returns + @{ allowed = $bool; reason = '' }. + #> + param([Parameter(Mandatory)] [string]$Url) + + $uri = $null + if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$uri)) { + return @{ allowed = $false; reason = 'malformed_url' } + } + if ($uri.Scheme -ne 'https') { + return @{ allowed = $false; reason = 'not_https' } + } + + $hostName = $uri.DnsSafeHost + $lower = $hostName.ToLowerInvariant() + if ($lower -eq 'localhost' -or $lower.EndsWith('.localhost') -or + $lower.EndsWith('.local') -or $lower.EndsWith('.internal')) { + return @{ allowed = $false; reason = 'internal_hostname' } + } + + # IP-literal host → check ranges directly (no DNS). + $literal = $null + if ([System.Net.IPAddress]::TryParse($hostName, [ref]$literal)) { + if (Test-IpBlocked -IpAddress $literal) { + return @{ allowed = $false; reason = 'blocked_ip_range' } + } + return @{ allowed = $true; reason = 'ok' } + } + + # Hostname → resolve and check every resolved address. Fail closed if it + # can't be resolved (a webhook to an unresolvable host is undeliverable + # anyway, and failing closed avoids surprises). + try { + $addresses = [System.Net.Dns]::GetHostAddresses($hostName) + } catch { + return @{ allowed = $false; reason = 'dns_resolution_failed' } + } + if (-not $addresses -or $addresses.Count -eq 0) { + return @{ allowed = $false; reason = 'dns_no_addresses' } + } + foreach ($addr in $addresses) { + if (Test-IpBlocked -IpAddress $addr) { + return @{ allowed = $false; reason = 'resolves_to_blocked_ip' } + } + } + return @{ allowed = $true; reason = 'ok' } +} + +function New-WebhookSignature { + <# + .SYNOPSIS + HMAC-SHA256 of the body keyed by the endpoint secret, as 'sha256='. + #> + param( + [Parameter(Mandatory)] [string]$Body, + [Parameter(Mandatory)] [AllowEmptyString()] [string]$Secret + ) + $hmac = [System.Security.Cryptography.HMACSHA256]::new([System.Text.Encoding]::UTF8.GetBytes($Secret)) + try { + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Body)) + } finally { + $hmac.Dispose() + } + $hex = -join ($hash | ForEach-Object { $_.ToString('x2') }) + return "sha256=$hex" +} + +function Get-WebhookDeliveryPlan { + <# + .SYNOPSIS + Return the endpoints that WOULD receive this event: those whose `events` + filter matches the event type AND whose URL passes HTTPS/SSRF validation. + Pure decision logic (no network), so it is unit-testable. + #> + param( + [Parameter(Mandatory)] $Event, + [Parameter(Mandatory)] $Config + ) + + $type = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } + $plan = @() + foreach ($ep in @($Config.endpoints)) { + if ($null -eq $ep) { continue } + $url = [string]$ep.url + if ([string]::IsNullOrWhiteSpace($url)) { continue } + + # Per-endpoint event filter. Empty/missing → match everything. + $filters = @($ep.events) + $matched = $filters.Count -eq 0 + foreach ($f in $filters) { + if ($type -like [string]$f) { $matched = $true; break } + } + if (-not $matched) { continue } + + $check = Test-WebhookUrlAllowed -Url $url + if (-not $check.allowed) { continue } + + $plan += ,([pscustomobject]@{ + url = $url + secret = [string]$ep.secret + events = $filters + }) + } + return $plan +} + +function Invoke-Sink { + param($Event, $Context) + + $cfg = $null + if ($null -ne $Context -and $null -ne $Context.Settings -and $null -ne $Context.Settings.events) { + $cfg = $Context.Settings.events.webhooks + } + if ($null -eq $cfg) { return @{ Success = $true; Message = 'webhooks: no config' } } + + $enabled = $false + try { $enabled = [bool]$cfg.enabled } catch { $enabled = $false } + if (-not $enabled) { return @{ Success = $true; Message = 'webhooks: disabled' } } + + $plan = @(Get-WebhookDeliveryPlan -Event $Event -Config $cfg) + if ($plan.Count -eq 0) { return @{ Success = $true; Message = 'webhooks: no matching endpoints' } } + + $type = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } + $body = $Event | ConvertTo-Json -Depth 12 -Compress + + $sent = 0 + $failed = 0 + foreach ($ep in $plan) { + $sig = New-WebhookSignature -Body $body -Secret $ep.secret + try { + Invoke-WebRequest -Uri $ep.url -Method POST -Body $body ` + -ContentType 'application/json; charset=utf-8' ` + -Headers @{ 'X-DotBot-Event' = $type; 'X-DotBot-Signature' = $sig } ` + -TimeoutSec 10 -SkipHttpErrorCheck -UseBasicParsing | Out-Null + $sent++ + } catch { + $failed++ + } + } + + return @{ + Success = ($failed -eq 0) + Message = "webhooks: sent=$sent failed=$failed of $($plan.Count)" + } +} + +Export-ModuleMember -Function @( + 'Invoke-Sink' + 'Test-IpBlocked' + 'Test-WebhookUrlAllowed' + 'New-WebhookSignature' + 'Get-WebhookDeliveryPlan' +) diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index ba141eb2..fa6756e1 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -490,6 +490,16 @@ function Read-DotbotMcpPreflightLine { function Test-DotbotMcpReadiness { param( [Parameter(Mandatory)] [string]$WorktreePath, + # Stable main repo root, exported as DOTBOT_STATE_ROOT so the preflight + # MCP process resolves runtime.json against the main .control/ rather + # than the worktree's junction, which can be stale on task retry + # (teardown/re-create is not atomic) and would make the server exit + # before the handshake. This mirrors the real provider session, which + # also runs with cwd/DOTBOT_PROJECT_ROOT = worktree and + # DOTBOT_STATE_ROOT = main root — so preflight tests the same config the + # task actually runs under. Omitted → no state-root override (backward + # compatible). See #515. + [string]$ProjectRoot, [string[]]$RequiredTools = @('task_get_context','task_set_status','task_update','decision_create','decision_list') ) @@ -539,6 +549,7 @@ function Test-DotbotMcpReadiness { $psi.WorkingDirectory = $WorktreePath $psi.Environment['DOTBOT_HOME'] = $frameworkRoot $psi.Environment['DOTBOT_PROJECT_ROOT'] = $WorktreePath + if ($ProjectRoot) { $psi.Environment['DOTBOT_STATE_ROOT'] = $ProjectRoot } $psi.Environment['__DOTBOT_MANAGED'] = '1' $maxAttempts = 2 @@ -699,8 +710,32 @@ function Test-TaskOutput { $taskOutputsDir = if ($Task -is [System.Collections.IDictionary]) { $Task['required_outputs_dir'] } else { $Task.required_outputs_dir } } if ($taskOutputs) { + # Output entries may be a bare filename ("x.md"), a repo-rooted path + # (".bot/workspace/product/x.md" — e.g. emitted by the deep-research + # generator), or an absolute path. $ProductDir is already + # /workspace/product, so naively Join-Path'ing a rooted entry + # double-joins (PowerShell only defers to the 2nd arg when it is + # drive-absolute) and validation always fails. Normalise first. + $projectRoot = Split-Path -Parent $BotRoot foreach ($f in $taskOutputs) { - if (-not (Test-Path (Join-Path $ProductDir $f))) { + $entry = ([string]$f -replace '\\', '/').Trim() + if ([string]::IsNullOrWhiteSpace($entry)) { continue } + # IsPathFullyQualified is the cross-platform "truly absolute" test: + # on Windows "C:/x" and "//host/x" are fully qualified but a bare "/x" + # is only drive-relative (correctly treated as relative here); on + # Linux/macOS "/tmp/x" is fully qualified. This avoids [IO.Path]:: + # IsPathRooted, which treats a bare leading "/" as rooted on Windows. + $target = if ([System.IO.Path]::IsPathFullyQualified($entry)) { + $entry + } elseif ($entry -match '^\.bot/') { + Join-Path $projectRoot $entry + } else { + Join-Path $ProductDir ($entry.TrimStart('/')) + } + # -LiteralPath: task-authored entries are untrusted; without it a + # filename containing [ ] would be treated as a wildcard (mis-validated), + # and it avoids a wildcard/existence probe on a crafted entry. + if (-not (Test-Path -LiteralPath $target)) { return "Task output not produced: $f" } } @@ -733,7 +768,19 @@ function Test-TaskOutput { if ($BaselineCount -ge 0) { $delta = $fileCount - $BaselineCount if ($delta -lt $minCount) { - return "Task output directory '$taskOutputsDir' produced $delta new file(s), expected at least $minCount" + # Resume-after-approval: on a resumed run the worktree already + # holds the artifact from the prior run, so the agent correctly + # calls task_set_status(done) without re-writing files that + # already exist — delta is 0 even though the required output is + # present and correct. For non-tasks/ outputs, fall back to the + # absolute file count: if the required files are already there + # (absolute count >= min), pass. tasks/ outputs keep strict + # delta enforcement because manifest pre-creation makes the + # absolute count always look satisfied, leaving delta the only + # meaningful signal. + if ($isTasksOutput -or $fileCount -lt $minCount) { + return "Task output directory '$taskOutputsDir' produced $delta new file(s), expected at least $minCount" + } } } elseif ($fileCount -lt $minCount) { return "Task output directory '$taskOutputsDir' has $fileCount file(s), expected at least $minCount" @@ -790,7 +837,12 @@ function Invoke-TaskClarificationLoopIfPresent { [string]$ModelName, [bool]$ShowDebug, [bool]$ShowVerbose, - [string]$PermissionMode + [string]$PermissionMode, + # Canonical project .bot root for process-registry writes (proc-*.json). + # Distinct from $BotRoot above, which is worktree-scoped (used for the + # answers-file path) — process state must never resolve through the + # worktree's transient .control junction (#612). + [string]$ProcessBotRoot ) $questionsPath = Join-Path $ProductDir "clarification-questions.json" if (-not (Test-Path $questionsPath)) { return $null } @@ -811,7 +863,7 @@ function Invoke-TaskClarificationLoopIfPresent { $PD.status = 'running' $PD.pending_questions = $null $PD.heartbeat_status = "Running task: $TaskName" - Write-ProcessFile -Id $Id -Data $PD + Write-ProcessFile -Id $Id -Data $PD -BotRoot $ProcessBotRoot } $questionsData = $null @@ -852,14 +904,14 @@ function Invoke-TaskClarificationLoopIfPresent { $ProcessData.product_dir = $ProductDir $ProcessData.answers_path = $answersPath $ProcessData.heartbeat_status = "Waiting for answers (task: $($Task.name))" - Write-ProcessFile -Id $ProcId -Data $ProcessData + Write-ProcessFile -Id $ProcId -Data $ProcessData -BotRoot $ProcessBotRoot while (-not (Test-Path $answersPath)) { - if (Test-ProcessStopSignal -Id $ProcId) { + if (Test-ProcessStopSignal -Id $ProcId -BotRoot $ProcessBotRoot) { $ProcessData.status = 'stopped' $ProcessData.failed_at = (Get-Date).ToUniversalTime().ToString("o") $ProcessData.pending_questions = $null - Write-ProcessFile -Id $ProcId -Data $ProcessData + Write-ProcessFile -Id $ProcId -Data $ProcessData -BotRoot $ProcessBotRoot return "Process stopped by user during clarification wait" } Start-Sleep -Seconds 2 @@ -926,15 +978,20 @@ function Invoke-TaskClarificationLoopIfPresent { Set-Content -Path $summaryPath -Value $newSummary -NoNewline } - # Forward slashes for cross-platform Join-Path safety (PostScriptRunner.psm1 - # uses the same normalisation — Windows accepts either separator, Unix does not). - $adjustPromptPath = Join-Path $BotRoot "recipes/includes/adjust-after-answers.md" - if (-not (Test-Path $adjustPromptPath)) { - # Escalate via the postScriptFailed path so the worktree merge is - # blocked. Without the adjust prompt the answers cannot be applied - # to artifacts; merging would be incorrect. + # Resolve the adjust-after-answers recipe through the canonical content + # resolver (project .bot/content/recipes -> user -> $DOTBOT_HOME/content + # /recipes). A hardcoded "$BotRoot/recipes/..." never resolves: content + # install never populates a .bot/recipes tree (the worktree recipe-link is + # gated on that same non-existent dir), so inside a task worktree the file + # was always missing and the apply-answers step wrongly escalated. + $adjustPromptPath = Resolve-DotbotContent -BotRoot $BotRoot -Type recipes -Name 'includes/adjust-after-answers.md' + if (-not $adjustPromptPath) { + # Only reachable when the recipe is absent from every content tier — + # i.e. a broken framework install, which no operator "needs-input" + # answer can fix. Fail loud with a clear message rather than parking + # the task in needs-input (and never silently skip the adjust pass). Reset-ClarificationState -PD $ProcessData -Id $ProcId -TaskName $Task.name - return "Adjust prompt not found at $adjustPromptPath — cannot apply clarification answers" + return "Adjust-after-answers recipe not found in project or framework content (content/recipes/includes/adjust-after-answers.md) — framework install may be broken." } $adjustContent = Get-Content $adjustPromptPath -Raw $adjustPrompt = @" @@ -1375,7 +1432,8 @@ try { # --- Multi-slot claim guard --- # When running with -Slot (concurrent workflow processes), another slot may # have claimed this task between our Get-NextWorkflowTask and this point. - # Only needed for prompt tasks — non-prompt tasks are guarded by the slot 0 check above. + # Only needed for prompt tasks — non-prompt tasks have their own claim guard + # before worktree creation below. if ($Slot -ge 0 -and $taskTypeCheck -eq 'prompt') { $claimOk = $false for ($claimAttempt = 0; $claimAttempt -lt 5; $claimAttempt++) { @@ -1474,6 +1532,57 @@ try { Write-Status "Auto-dispatching $taskTypeVal task: $($task.name)" -Type Process Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Auto-dispatch $taskTypeVal task: $($task.name)" + # --- Non-prompt task claim guard (before worktree) --- + # Unconditional (no $Slot guard): covers standalone runners (Slot = null/0) + # AND multi-slot runners on slot 0. The prompt-task guard above is $Slot-gated + # because prompt concurrency only occurs in multi-slot mode; non-prompt races + # also occur between standalone processes sharing the same task pool. + $claimOk = $false + $claimAttemptsMade = 0 + for ($claimAttempt = 0; $claimAttempt -lt 5; $claimAttempt++) { + $claimAttemptsMade++ + try { + $claimResult = $null + if ($task.status -notin @('todo', 'needs-input', 'in-progress')) { + throw "Cannot dispatch non-prompt task '$($task.id)' from status '$($task.status)'" + } + if ($task.status -ne 'in-progress') { + $claimResult = Invoke-TaskMarkInProgress -Arguments @{ task_id = $task.id } + } + if ($claimResult -and -not $claimResult.success) { + $errMsg = if ($claimResult.message) { $claimResult.message } else { "HTTP $($claimResult.status_code)" } + throw "Claim failed: $errMsg" + } + if ($claimResult -and $claimResult.body -and $claimResult.body.no_op) { + throw "Task already claimed" + } + if ($claimResult) { $task.status = 'in-progress' } + $claimOk = $true + break + } catch { + $errMsg = $_.Exception.Message + if ($errMsg -notmatch 'already claimed|Claim failed') { + Write-Status "Fatal error claiming task $($task.id): $errMsg" -Type Error + throw + } + Write-Diag "Task $($task.id) claimed by another runner, retrying ($taskTypeVal)..." + Start-Sleep -Milliseconds 200 + # Break unconditionally — outer loop re-fetches and re-processes the next + # task with full task_gen/prompt_template recovery. Fetching here is dead + # code (result discarded on break) and opens a small race window. + break + } + } + if (-not $claimOk) { + Write-Status "Could not claim a $taskTypeVal task after $claimAttemptsMade attempts" -Type Warn + Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Could not claim $taskTypeVal task after $claimAttemptsMade attempts" + if ($Continue) { Start-Sleep -Seconds 2; continue } else { break } + } + # Task may have been replaced during claim retry; re-sync process metadata. + $processData.task_id = $task.id + $processData.task_name = $task.name + $env:DOTBOT_CURRENT_TASK_ID = $task.id + $worktreePath = $null $branchName = $null $worktreeSetup = Initialize-DotbotTaskWorktreeForProcess -Task $task ` @@ -1485,9 +1594,6 @@ try { $executionBotRoot = Join-Path $worktreePath ".bot" $executionProductDir = Join-Path (Join-Path $executionBotRoot 'workspace') 'product' - # Mark in-progress - Set-TaskInProgressForExecutorDispatch -Task $task - $typeSuccess = $false $typeError = $null $typeMergeBlocked = $false @@ -1710,7 +1816,7 @@ try { } Write-Status "Checking dotbot MCP tools..." -Type Process - $mcpReady = Test-DotbotMcpReadiness -WorktreePath $worktreePath + $mcpReady = Test-DotbotMcpReadiness -WorktreePath $worktreePath -ProjectRoot $projectRoot if (-not $mcpReady.ok) { throw "dotbot MCP preflight failed ($($mcpReady.reason)): $($mcpReady.message)" } @@ -1862,10 +1968,10 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status if ($attemptNumber -gt 1) { Write-Status "Retry attempt $attemptNumber of $maxRetriesPerTask" -Type Warn } - if (Test-ProcessStopSignal -Id $procId) { + if (Test-ProcessStopSignal -Id $procId -BotRoot $botRoot) { $processData.status = 'stopped' $processData.failed_at = (Get-Date).ToUniversalTime().ToString("o") - Write-ProcessFile -Id $procId -Data $processData + Write-ProcessFile -Id $procId -Data $processData -BotRoot $botRoot break } @@ -1914,7 +2020,7 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status # Update heartbeat $processData.last_heartbeat = (Get-Date).ToUniversalTime().ToString("o") - Write-ProcessFile -Id $procId -Data $processData + Write-ProcessFile -Id $procId -Data $processData -BotRoot $botRoot # Check completion $completionCheck = Test-TaskCompletion -TaskId $task.id @@ -1943,12 +2049,19 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status # Task not completed - log diagnostic to help distinguish failure modes. $stillInProgress = $false $nowNeedsInput = $false + $doneHookBlock = $null try { $currentTask = Get-WorkflowTaskContent -Task $task -RunDir $runDir if ($currentTask -and $currentTask.Content) { $currentStatus = [string]$currentTask.Content.status $stillInProgress = ($currentStatus -eq 'in-progress') $nowNeedsInput = ($currentStatus -eq 'needs-input') + # Breadcrumb left by the transition layer when a verify hook + # aborted this task's done-transition (e.g. privacy scan). + $ext = $currentTask.Content.extensions + if ($ext -and $ext.runner -and $ext.runner.done_transition_block) { + $doneHookBlock = $ext.runner.done_transition_block + } } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } @@ -1962,6 +2075,28 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status break } + # A verify hook (e.g. privacy scan) blocked the done-transition: park to + # needs-input so the operator fixes the flagged content, instead of + # burning retries and mislabeling a done task skipped(max-retries). + # Checked before the harness-error classification so this definitive + # on-disk signal isn't pre-empted by heuristic text matching. + if ($doneHookBlock) { + $hookName = [string]$doneHookBlock.hook + $hookMsg = [string]$doneHookBlock.message + # Defensive cap (breadcrumb is already truncated at the source) so an + # oversized hook message can't bloat the task record / handoff — mirrors AuthError. + if ($hookMsg.Length -gt 1000) { $hookMsg = $hookMsg.Substring(0, 1000) + " … [truncated, showing 1000 of $($hookMsg.Length) chars]" } + $blockContext = "The done-transition was blocked by verify hook '$hookName': $hookMsg`nThe task's declared work may already be complete — resolve the flagged content (e.g. redact absolute local paths from the committed artifact), then re-run or approve. The retry budget was not consumed." + Write-Status "Done-transition blocked by verify hook — parking for operator: $($task.name)" -Type Warn + Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Task '$($task.name)' parked (needs-input): verify hook '$hookName' blocked the done-transition" + Set-WorkflowTaskNeedsInput -Task $task -RunDir $runDir ` + -QuestionId "verify-hook-block-$($task.id)" ` + -Question "Verify hook blocked completion of '$($task.name)'" ` + -Context $blockContext | Out-Null + $taskParked = $true + break + } + if ($stillInProgress) { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Completion check failed (attempt $attemptNumber): '$($task.name)' still has status in-progress. Check activity log: if a 'task_set_status(done) blocked' entry exists, verification failed; otherwise task_set_status(done) was likely never called." } else { @@ -2040,7 +2175,8 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status -ProductDir $executionProductDir -ProcessData $processData -ProcId $procId ` -ProjectRoot $worktreePath ` -ModelName $modelTier -ShowDebug $ShowDebug ` - -ShowVerbose $ShowVerbose -PermissionMode $permissionMode + -ShowVerbose $ShowVerbose -PermissionMode $permissionMode ` + -ProcessBotRoot $botRoot if ($clarErr) { $taskSuccess = $false $postScriptFailed = $true @@ -2403,6 +2539,7 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status $processData.completed_at = (Get-Date).ToUniversalTime().ToString("o") } if ($RunId) { + $runStatus = 'running' try { $runStatus = switch ([string]$processData.status) { 'completed' { 'completed' } @@ -2414,6 +2551,14 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status } catch { Write-BotLog -Level Warn -Message "Failed to update WorkflowRun live status for $RunId" -Exception $_ } + + if ($runStatus -in @('completed', 'failed', 'cancelled')) { + try { + Write-ActivityEvent -BotRoot $botRoot -Type "workflow.run_$runStatus" -RunId $RunId -From 'running' -To $runStatus -Actor 'system' -Reason $processData.error + } catch { + Write-BotLog -Level Warn -Message "Failed to emit workflow.run_$runStatus event for $RunId" -Exception $_ + } + } } if ($integrationBranch) { diff --git a/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj b/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj index 7088a7a0..f0529b6c 100644 --- a/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj +++ b/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj @@ -30,4 +30,13 @@ + + + + + diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml b/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml index 26688af4..6920999e 100644 --- a/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml +++ b/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml @@ -1,42 +1,20 @@ @page @model Dotbot.Server.Pages.IndexModel @{ - Layout = null; + Layout = "_LayoutDashboard"; + ViewData["Title"] = "Dashboard"; + ViewData["UserName"] = Model.UserName; } - - - - - - Dotbot Dashboard - - - - - - -
- -
-
- - DASHBOARD -
-
- - SIGN OUT -
-
- -
- - - -
+ +
+ + + +
- -
+ +
@@ -140,13 +118,6 @@
- - -
- +@section Scripts { - - +} diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_Layout.cshtml b/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_Layout.cshtml index 55eba38b..35e65386 100644 --- a/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_Layout.cshtml +++ b/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_Layout.cshtml @@ -7,43 +7,11 @@ + @* Design tokens now come from the shared stylesheet (single source of truth, + #551/#604). Values are identical to the inline block this replaces; the + remaining inline component styles below move to the shared shell in #605. *@ + + + + +
+ + + + reduced motion: toggle via OS / devtools emulation — transitions must stop +
+ +
+
+ +
+ + + +
+ + + +
+
+ + + +
+
+

SHELL HARNESS

+

+ Content area. Grid texture at token opacity, no scanlines here — + chrome regions (top bar, rail, ticker) carry them instead. +

+
+
Mock module panel
+
+
42
+
Phosphor-glow stat — check glow follows preset
+
+
+
+
+ +
+ DISPATCH · 03 · task T-042 moved to analysed · process P-09 started · all systems nominal + +
+ +
+
+ + + + + diff --git a/src/studio-ui/src/client/styles/globals.css b/src/studio-ui/src/client/styles/globals.css index 1a2b206f..34b0a001 100644 --- a/src/studio-ui/src/client/styles/globals.css +++ b/src/studio-ui/src/client/styles/globals.css @@ -4,6 +4,9 @@ @import '@shared/css/dotbot-tokens.css'; @import '@shared/css/dotbot-crt.css'; +/* v4 navigation shell (#551/#604) — no .shell markup in Studio yet; importing + * keeps the stylesheet build-checked and ready for the R2 consolidation. */ +@import '@shared/css/dotbot-shell.css'; /* === App Layout === */ #root { diff --git a/src/ui/README.md b/src/ui/README.md index 6604a019..1d0e3a5f 100644 --- a/src/ui/README.md +++ b/src/ui/README.md @@ -6,7 +6,7 @@ A minimal, dependency-free PowerShell web server for monitoring and controlling - **CP/M-inspired terminal aesthetic** with Axiome Design amber accents - **Real-time monitoring** via auto-polling (3-5 second intervals) -- **Task queue visualization** (TODO/Analysing/Analysed/In-Progress/Done) +- **Task queue visualization** for workflow-run and standalone tasks, including Needs Review and legacy analysis buckets when present - **Process management** - launch, stop, kill, and whisper to tracked processes - **Localhost-only** - no authentication needed - **Zero dependencies** - pure PowerShell + vanilla HTML/CSS/JS @@ -56,13 +56,12 @@ All processes are tracked via JSON files in `.bot/.control/processes/`: │ └── activity.jsonl # Global activity log └── workspace/ └── tasks/ - ├── todo/ - ├── analysing/ - ├── analysed/ - ├── in-progress/ - └── done/ + ├── workflow-runs/ # Per-run directories with run.json + task JSON files + └── standalone/ # Standalone task JSON files ``` +Task status is stored in each task JSON file; task files do not move between per-status directories. + ## API Endpoints ### `GET /api/state` @@ -75,7 +74,7 @@ Returns all tracked processes with status. Launch a new process: ```json { - "type": "analysis", + "type": "task-runner", "continue": true, "model": "best" } diff --git a/src/ui/modules/ProductAPI.psm1 b/src/ui/modules/ProductAPI.psm1 index 064e2625..a5320906 100644 --- a/src/ui/modules/ProductAPI.psm1 +++ b/src/ui/modules/ProductAPI.psm1 @@ -31,6 +31,91 @@ function Initialize-ProductAPI { $script:Config.ControlDir = $ControlDir } +function Get-PendingReviewProductRoot { + $botRoot = $script:Config.BotRoot + + # Collect task IDs in needs-review state from all task layouts: + # - schema v2: workflow-runs/{run}/*.json and standalone/*.json (status field) + # - legacy flat: workspace/tasks/needs-review/*.json + $reviewTaskIds = [System.Collections.Generic.HashSet[string]]@() + $tasksRoot = Join-Path $botRoot "workspace/tasks" + + $taskSearchDirs = [System.Collections.Generic.List[string]]@() + # v2 layout + $wrDir = Join-Path $tasksRoot "workflow-runs" + if (Test-Path $wrDir) { + Get-ChildItem -Path $wrDir -Directory -ErrorAction SilentlyContinue | + ForEach-Object { $taskSearchDirs.Add($_.FullName) } + } + $saDir = Join-Path $tasksRoot "standalone" + if (Test-Path $saDir) { $taskSearchDirs.Add($saDir) } + # legacy flat layout + $legacyDir = Join-Path $tasksRoot "needs-review" + if (Test-Path $legacyDir) { $taskSearchDirs.Add($legacyDir) } + + foreach ($dir in $taskSearchDirs) { + Get-ChildItem -Path $dir -Filter "*.json" -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne 'run.json' } | + ForEach-Object { + try { + $taskData = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + # v2 tasks: check status field; legacy tasks: presence in needs-review dir is enough (id field required) + $isReview = ($taskData.status -eq 'needs-review') -or ($dir -eq $legacyDir) + if ($isReview -and $taskData.id) { [void]$reviewTaskIds.Add($taskData.id) } + } catch { + Write-BotLog -Level Debug -Message "Skipping malformed task file '$($_.FullName)'" -Exception $_ + } + } + } + if ($reviewTaskIds.Count -eq 0) { return @() } + + $mapPath = Join-Path $script:Config.ControlDir "worktree-map.json" + if (-not (Test-Path -LiteralPath $mapPath)) { return @() } + try { + $worktreeMap = Get-Content -LiteralPath $mapPath -Raw | ConvertFrom-Json + } catch { + Write-BotLog -Level Warning -Message "Failed to parse worktree-map.json at '$mapPath'" -Exception $_ + return @() + } + + $roots = [System.Collections.Generic.List[hashtable]]@() + foreach ($prop in $worktreeMap.PSObject.Properties) { + $taskId = $prop.Name + if (-not $reviewTaskIds.Contains($taskId)) { continue } + $entry = $prop.Value + if ([string]::IsNullOrWhiteSpace($entry.worktree_path)) { continue } + $productDir = Join-Path $entry.worktree_path ".bot" "workspace" "product" + if (Test-Path -LiteralPath $productDir) { + $roots.Add(@{ + ProductDir = $productDir + TaskId = $taskId + TaskName = $entry.task_name + }) + } + } + return $roots +} + +# Builds response hashtable for raw file download (Found, MimeType, BinaryData|TextContent). +function New-RawProductResponse { + param([Parameter(Mandatory)][string]$FullPath) + $ext = [System.IO.Path]::GetExtension($FullPath).ToLowerInvariant() + $mimeType = switch ($ext) { + '.png' { 'image/png' } + '.jpg' { 'image/jpeg' } + '.jpeg' { 'image/jpeg' } + '.gif' { 'image/gif' } + '.svg' { 'image/svg+xml' } + '.txt' { 'text/plain; charset=utf-8' } + default { 'application/octet-stream' } + } + $isBinary = $ext -in @('.png', '.jpg', '.jpeg', '.gif') + if ($isBinary) { + return @{ Found = $true; MimeType = $mimeType; BinaryData = [System.IO.File]::ReadAllBytes($FullPath) } + } + return @{ Found = $true; MimeType = $mimeType; TextContent = (Get-Content -LiteralPath $FullPath -Raw) } +} + function Resolve-ProductDocumentInfo { param( [Parameter(Mandatory)] [System.IO.FileInfo]$File, @@ -257,6 +342,28 @@ function Get-ProductList { } } + $existingNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($d in $docs) { [void]$existingNames.Add($d['name']) } + foreach ($root in (Get-PendingReviewProductRoot)) { + $pendingFiles = @(Get-ChildItem -Path $root.ProductDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne '.gitkeep' }) + foreach ($file in $pendingFiles) { + $doc = Resolve-ProductDocumentInfo -File $file -ProductDir $root.ProductDir + if ($existingNames.Contains($doc.Name)) { continue } + [void]$existingNames.Add($doc.Name) + $docs += @{ + name = $doc.Name + filename = $doc.Filename + depth = $doc.Depth + type = $doc.Type + size = $doc.Size + pending_review = $true + task_id = $root.TaskId + task_name = $root.TaskName + } + } + } + return @{ docs = $docs } } @@ -275,13 +382,21 @@ function Get-ProductDocument { name = $resolvedDoc.Name content = $docContent } - } else { - return @{ - _statusCode = 404 - success = $false - error = "Document not found: $Name" + } + + foreach ($root in (Get-PendingReviewProductRoot)) { + $pendingDoc = Resolve-ProductDocumentPath -Name $Name -ProductDir $root.ProductDir + if ($pendingDoc -and (Test-Path -LiteralPath $pendingDoc.FullPath)) { + $docContent = Get-Content -LiteralPath $pendingDoc.FullPath -Raw + return @{ success = $true; name = $pendingDoc.Name; content = $docContent } } } + + return @{ + _statusCode = 404 + success = $false + error = "Document not found: $Name" + } } function Get-ProductDocumentRaw { @@ -292,35 +407,18 @@ function Get-ProductDocumentRaw { $productDir = Join-Path $botRoot "workspace/product" $resolvedDoc = Resolve-ProductDocumentPath -Name $Name -ProductDir $productDir - if (-not $resolvedDoc -or -not (Test-Path -LiteralPath $resolvedDoc.FullPath)) { - return @{ Found = $false } - } - - $ext = [System.IO.Path]::GetExtension($resolvedDoc.FullPath).ToLowerInvariant() - $mimeType = switch ($ext) { - '.png' { 'image/png' } - '.jpg' { 'image/jpeg' } - '.jpeg' { 'image/jpeg' } - '.gif' { 'image/gif' } - '.svg' { 'image/svg+xml' } - '.txt' { 'text/plain; charset=utf-8' } - default { 'application/octet-stream' } + if ($resolvedDoc -and (Test-Path -LiteralPath $resolvedDoc.FullPath)) { + return New-RawProductResponse -FullPath $resolvedDoc.FullPath } - $isBinary = $ext -in @('.png', '.jpg', '.jpeg', '.gif') - if ($isBinary) { - return @{ - Found = $true - MimeType = $mimeType - BinaryData = [System.IO.File]::ReadAllBytes($resolvedDoc.FullPath) - } - } else { - return @{ - Found = $true - MimeType = $mimeType - TextContent = (Get-Content -LiteralPath $resolvedDoc.FullPath -Raw) + foreach ($root in (Get-PendingReviewProductRoot)) { + $pendingDoc = Resolve-ProductDocumentPath -Name $Name -ProductDir $root.ProductDir + if ($pendingDoc -and (Test-Path -LiteralPath $pendingDoc.FullPath)) { + return New-RawProductResponse -FullPath $pendingDoc.FullPath } } + + return @{ Found = $false } } function Get-PreflightResults { diff --git a/src/ui/server.ps1 b/src/ui/server.ps1 index ef5c9569..c9c0bade 100644 --- a/src/ui/server.ps1 +++ b/src/ui/server.ps1 @@ -2577,7 +2577,12 @@ $docContext @{ run_id = $run.run_id; status = 'failed'; completed_at = $failTs; last_heartbeat = $failTs; error = $_.Exception.Message } } $failStatus | ConvertTo-Json -Depth 20 | Set-Content -Path $run.live_status_path -Encoding utf8NoBOM - } catch { Write-BotLog -Level Debug -Message "Failed to mark orphaned run failed" -Exception $_ } + $orphanFailReason = $_.Exception.Message + if (-not (Get-Command Write-ActivityEvent -ErrorAction SilentlyContinue)) { + Import-Module (Join-Path $PSScriptRoot ".." "runtime" "Modules" "Dotbot.Runtime" "Dotbot.Runtime.psd1") -DisableNameChecking -Global -ErrorAction Stop + } + Write-ActivityEvent -BotRoot $botRoot -Type 'workflow.run_failed' -RunId $run.run_id -From 'running' -To 'failed' -Actor 'system' -Reason $orphanFailReason + } catch { Write-BotLog -Level Debug -Message "Failed to mark orphaned run failed or emit event" -Exception $_ } } $statusCode = 500 $content = @{ success = $false; error = "Failed to run workflow: $($_.Exception.Message)" } | ConvertTo-Json -Compress @@ -2827,6 +2832,26 @@ $docContext break } + # Shared shell/token stylesheets live outside the static root (src/shared/css). + # Explicit route with a traversal guard; .css only — nothing else in that + # directory is servable (e.g. harness.html is dev-only). + { $_ -like '/shared/css/*' } { + $sharedCssRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..' 'shared' 'css')) + $requested = $url.Substring('/shared/css/'.Length) + $candidate = [System.IO.Path]::GetFullPath((Join-Path $sharedCssRoot $requested)) + $rootWithSep = $sharedCssRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if ($candidate.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase) -and + [System.IO.Path]::GetExtension($candidate) -eq '.css' -and + (Test-Path -LiteralPath $candidate -PathType Leaf)) { + $contentType = 'text/css; charset=utf-8' + $content = Get-Content -LiteralPath $candidate -Raw + } else { + $statusCode = 404 + $content = "Not found: $url" + } + break + } + default { # Serve static files $filePath = Join-Path $staticRoot $url.TrimStart('/') diff --git a/src/ui/static/app.js b/src/ui/static/app.js index 317d7fbc..7c5c94d6 100644 --- a/src/ui/static/app.js +++ b/src/ui/static/app.js @@ -30,6 +30,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize UI components initTabs(); + initShell(); initLogoClick(); initHamburgerMenu(); initSidebarCollapse(); diff --git a/src/ui/static/css/layout.css b/src/ui/static/css/layout.css index 2139f24f..ac32adc6 100644 --- a/src/ui/static/css/layout.css +++ b/src/ui/static/css/layout.css @@ -1,33 +1,27 @@ /* DOTBOT Control Panel - Layout * Page structure: control panel, header, footer, main content area */ -/* ========== LAYOUT ========== */ -.control-panel { - display: flex; - flex-direction: column; - height: 100vh; - background: var(--bg-deep); -} - -/* ========== HEADER ========== */ -.header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 20px; - background: var(--bezel-dark); - border-bottom: 1px solid var(--bezel-edge); - flex-shrink: 0; +/* ========== LAYOUT ========== + * Page chrome (topbar/rail/ticker) comes from /shared/css/dotbot-shell.css + * (#551/#605). This file keeps app-side pieces that live inside the shell: + * context group, badges, pickers, LEDs, editor button, main-layout/sidebar. */ + +/* Glue: the cockpit layout fills the shell content area without the 1280px + * reading cap — resizable sidebar + pipeline columns need full width. */ +.shell-content > .main-layout { + height: 100%; } -.system-id { +/* App-side topbar group: project badge + runtime picker + workflow badges */ +.shell-context-group { display: flex; align-items: center; - gap: 16px; + gap: 12px; + min-width: 0; } .logo { - font-size: 16px; + font-size: 14px; font-weight: 700; letter-spacing: 0.15em; color: var(--color-primary); @@ -229,11 +223,6 @@ 100% { transform: scale(1); } } -.header-signals { - display: flex; - gap: 24px; -} - .signal { display: flex; align-items: center; @@ -269,7 +258,7 @@ 50% { opacity: 0.4; } } -/* Framework banner (DOTBOT_HOME / git state) — lives in .header-signals so +/* Framework banner (DOTBOT_HOME / git state) — lives in the shell topbar so the active checkout is the first thing a dev sees on every page paint. Default state stays muted; --warn pops to the warning palette when the framework tree is dirty or off main/master. */ @@ -359,82 +348,8 @@ overflow: hidden; } -/* Tab Bar Container (Full Width) */ -.tab-bar-container { - background: var(--bezel-dark); - border-bottom: 1px solid var(--bezel-edge); - flex-shrink: 0; -} - -/* Tab Bar */ -.tab-bar { - display: flex; - justify-content: space-between; - align-items: center; - background: var(--bezel-dark); - flex-shrink: 0; - padding-right: 12px; -} - -.tab-group-main { - display: flex; -} - -.tab { - padding: 10px 18px; - background: none; - border: none; - border-bottom: 2px solid transparent; - font-family: var(--font-ui); - font-size: 10px; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--label-color); - cursor: pointer; - transition: all 0.2s ease; -} - -.tab:hover { - color: var(--color-primary-dim); - background: var(--primary-05); -} - -.tab.active { - color: var(--color-primary); - border-bottom-color: var(--color-primary); - background: var(--primary-08); -} - -/* Tab Action Button (Whisper) */ -.tab-action { - display: flex; - align-items: center; - gap: 6px; - background: transparent; - border: 1px solid var(--color-secondary-dim); - color: var(--color-secondary); - padding: 6px 12px; - font-family: var(--font-ui); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - border-radius: 3px; - cursor: pointer; - transition: all 0.2s ease; -} - -.tab-action:hover { - background: var(--secondary-15); - border-color: var(--color-secondary); - box-shadow: 0 0 8px var(--secondary-glow); -} - -.tab-action svg { - width: 14px; - height: 14px; -} +/* Tab bar chrome removed in #605 — navigation lives in the shell rail + * (/shared/css/dotbot-shell.css). Panes below are still driven by tabs.js. */ /* Tab Content */ .tab-content { @@ -453,46 +368,41 @@ display: block; } -/* ========== FOOTER ========== */ -.footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 20px; - background: var(--bezel-dark); - border-top: 1px solid var(--bezel-edge); - font-size: 10px; - flex-shrink: 0; -} - -.footer-left, -.footer-right { - display: flex; - align-items: center; - gap: 8px; -} +/* Footer chrome removed in #605 — replaced by the shell ticker slot + * (/shared/css/dotbot-shell.css); scroll behaviour lands with #607. */ -.footer-center { - flex: 1; - text-align: center; +/* Topbar density: nothing wraps in the 44px bar. Priority under pressure: + * identity values (project, workflow, DOTBOT_HOME SHA) keep full text; the + * inert search pill compresses first; workflow pills ellipsize last — the + * full list lives on the Workflows tab. */ +.shell-topbar .workflow-pills { + flex-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } -.footer-label { - color: var(--label-color); - text-transform: uppercase; - letter-spacing: 0.05em; +.shell-topbar .project-badge, +.shell-topbar .workflow-badge, +.shell-topbar .framework-banner { + white-space: nowrap; + flex-shrink: 0; } -.footer-value { - color: var(--type-color, var(--color-primary-dim)); - font-weight: 500; +.shell-topbar .framework-banner__value { + white-space: nowrap; } -.footer-sep { - color: var(--bezel-edge); +.shell-topbar .shell-search { + min-width: 120px; + flex-shrink: 1; + overflow: hidden; } -.footer-mission { - color: var(--label-color); - font-style: italic; +@media (max-width: 1200px) { + .shell-topbar .shell-search, + .shell-topbar .workflow-pills { + display: none; + } } diff --git a/src/ui/static/css/responsive.css b/src/ui/static/css/responsive.css index 2071e615..a8ca0151 100644 --- a/src/ui/static/css/responsive.css +++ b/src/ui/static/css/responsive.css @@ -64,7 +64,9 @@ display: flex; } - .system-id { + .shell-context-group, + .shell-search, + #framework-banner { display: none; } @@ -133,11 +135,7 @@ padding-left: 16px; } - /* Header signals: compact */ - .header-signals { - gap: 12px; - } - + /* Topbar signals: LEDs only, no text labels */ .signal span:not(.led) { display: none; } @@ -181,14 +179,4 @@ .stat-card { padding: 10px 12px; } - - /* Tab bar: scrollable */ - .tab-bar { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } - - .tab-group-main { - flex-wrap: nowrap; - } } diff --git a/src/ui/static/index.html b/src/ui/static/index.html index a9440596..b624a2fd 100644 --- a/src/ui/static/index.html +++ b/src/ui/static/index.html @@ -15,6 +15,7 @@ +