Skip to content

MCP server resolves DotbotProjectRoot to the worktree, silently discarding all agent-driven task-state transitions #356

Description

@andresharpe

TL;DR

The dotbot MCP server (core/mcp/dotbot-mcp.ps1) resolves $global:DotbotProjectRoot by walking up from $PSScriptRoot until it finds something at .git. The Test-Path check matches both a .git directory and the .git gitfile that lives at the root of every git worktree. So when the MCP server is launched from a per-task worktree (the standard dotbot convention) it sets DotbotProjectRoot to the worktree root rather than the main repo. Every MCP tool reads that variable to locate .bot/workspace/tasks/, so every agent-driven task-state transition (task_mark_needs_input, task_mark_done, task_mark_skipped, task_mark_analysed, task_answer_question, etc.) writes to the worktree. Complete-TaskWorktree then runs git checkout -- .bot/workspace/tasks/ on the worktree before the squash-merge, silently discarding every one of those writes.

Net effect: any change the agent makes to task state from within a worktree is invisible to the parent. PR #352's loop-escalation fix (move failed execution to needs-input) is no-op'd in worktree runs. So is mark_done, so is everything else.

Repro

A live SlashOps dogfood run on 2026-04-27 (process proc-8a41a3, started 13:07Z, runner stopped by the operator around 14:01Z when the task hit a privacy-scan blocker). All paths and timestamps below come from .bot/.control/activity.jsonl and .bot/.control/processes/proc-8a41a3.activity.jsonl of that run.

Activity log timeline for task Product Documents (id 37dcd1a5-9c6f-4d42-b01c-cee834589f12):

13:30:31  mcp__dotbot__task_mark_needs_input   (called from worktree's Claude session)
13:31:03  Squash-merged to main: Product Documents
13:31:03  Task completed (analyse+execute): Product Documents

State on disk after the merge:

.bot/workspace/tasks/in-progress/product-documents-37dcd1a5.json
  status:           in-progress
  updated_at:       2026-04-27T13:11:27Z   ← original execute-phase timestamp, never updated
  pending_question: null
  completed_at:     null

The merge commit (004ec1257e712a0562eb80fbe56f367cf61370cb, "feat: Product Documents [task:37dcd1a5]") only touches .gemini/settings.json. The task file is bit-identical to the prior 9ba6fff chore: save autonomous task state commit. The mark_needs_input call at 13:30:31 left no trace in the parent.

Root cause (verified by source read)

Three steps, all in framework code:

1. core/mcp/dotbot-mcp.ps1:30-39

# Auto-detect project root by walking up from script location to find .git folder
$script:ProjectRoot = $null
$currentPath = $PSScriptRoot
while ($currentPath) {
    if (Test-Path (Join-Path $currentPath ".git")) {
        $script:ProjectRoot = $currentPath
        break
    }
    $parent = Split-Path $currentPath -Parent
    if ($parent -eq $currentPath) { break }
    $currentPath = $parent
}

Walks up looking for .git. Test-Path (no -PathType Container) succeeds on both directories and files. A worktree's root contains a .git file of the form gitdir: /path/to/main/.git/worktrees/<id>. So the walk stops at the worktree root and $script:ProjectRoot resolves to the worktree, not the main repo. $global:DotbotProjectRoot = $script:ProjectRoot (line 47) makes that visible to every dot-sourced tool.

2. core/mcp/tools/task-mark-needs-input/script.ps1:2-3

Import-Module (Join-Path $global:DotbotProjectRoot ".bot/core/mcp/modules/SessionTracking.psm1") -Force
Import-Module (Join-Path $global:DotbotProjectRoot ".bot/core/mcp/modules/TaskStore.psm1") -Force

The tool resolves modules — and ultimately the task tree — relative to that variable. Set-TaskState writes the new file under Join-Path $global:DotbotProjectRoot ".bot/workspace/tasks/needs-input/..." and removes the old one from tasks/in-progress/. Both operations happen in the worktree's tasks tree.

Same pattern in every other MCP tool that touches task state — task-mark-done, task-mark-skipped, task-mark-analysed, task-mark-in-progress, task-mark-todo, task-answer-question, task-approve-split, etc. They all dot-source TaskStore.psm1 from $global:DotbotProjectRoot. (Suggest a sweep: grep -l 'global:DotbotProjectRoot' core/mcp/tools/.)

3. core/runtime/modules/WorktreeManager.psm1:670-718

Complete-TaskWorktree runs in the parent repo at the end of a task. Before any merge it does:

# Restore tracked files that were replaced by junctions
git -C $worktreePath checkout -- .bot/workspace/tasks 2>$null
git -C $worktreePath checkout -- .bot/workspace/product 2>$null

(line 674-675 — restores from worktree's HEAD), and later, against the parent:

# Clean tracked + untracked task files so merge can proceed cleanly
git -C $ProjectRoot checkout -- .bot/workspace/tasks/ 2>$null
git -C $ProjectRoot clean -fd -- .bot/workspace/tasks/ 2>$null

(line 717-718 — wipes whatever the parent had). The parent then merges, and a backup-restore at line 768-775 puts back the snapshot of the parent's task tree taken at line 705-714 (just before the git checkout).

The design clearly assumes the parent repo holds the canonical task state and the worktree's task changes are noise — hence the unconditional reset of .bot/workspace/tasks/ in the worktree before merge. The MCP path (steps 1 and 2 above) violates that assumption by writing the canonical state into the worktree.

Blast radius

  • PR Fix init prompt paths, unapproved verbs, and analysis loop #352's loop-escalation fix (move failed execution to needs-input so the runner stops looping) is silently no-op'd in worktree runs. The repro above is exactly that mechanism failing — the wedged file in in-progress/ has no pending_question, no updated updated_at.
  • Every other agent-driven task transition is affected the same way: mark_done, mark_skipped, answer_question, approve_split, etc. The fact that things sometimes appear to work is likely because a separate runtime path in the parent (e.g. Invoke-WorkflowProcess.ps1 calling Move-Task or similar in-process) re-derives state from worktree commits or invokes the same MCP tools again later — that path needs investigation.
  • Affects every dotbot install that uses worktree isolation, not just SlashOps. Any project running start-from-prompt (or any other workflow that uses worktrees) would see the same wedge.

Suggested fix

Resolve DotbotProjectRoot to the main repo root regardless of whether the launcher's cwd is a worktree. The cleanest signal git already provides:

# In dotbot-mcp.ps1, replacing lines 28-44 (approximate)
$gitCommonDir = git -C $PSScriptRoot rev-parse --git-common-dir 2>$null
if ($LASTEXITCODE -eq 0 -and $gitCommonDir) {
    # rev-parse returns '.git' relative to cwd in non-worktree, or an absolute
    # path to the main .git/ in a worktree. Resolve and walk up to its parent.
    $resolved = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot $gitCommonDir) -ErrorAction SilentlyContinue)
    if ($resolved) {
        $script:ProjectRoot = Split-Path $resolved.Path -Parent
    }
}

# Fallback: keep the existing walk-up for the no-git case (e.g. tests)
if (-not $script:ProjectRoot) {
    $currentPath = $PSScriptRoot
    while ($currentPath) {
        if (Test-Path (Join-Path $currentPath ".git")) {
            $script:ProjectRoot = $currentPath
            break
        }
        $parent = Split-Path $currentPath -Parent
        if ($parent -eq $currentPath) { break }
        $currentPath = $parent
    }
}

git rev-parse --git-common-dir:

  • In the main repo: returns .git (relative).
  • In a worktree: returns the absolute path to the main .git/ directory.

Either way, Split-Path -Parent of the resolved path gives the main repo's toplevel.

Regression test

Dotbot tests are imperative scripts under tests/ using Test-Helpers.psm1 (the project does not use Pester and is not opting in yet). Add a test fixture that:

  1. Creates a temp project with New-TestProject, lays down core/ via the existing test helper that mirrors dotbot init.
  2. Seeds a task in .bot/workspace/tasks/in-progress/.
  3. Creates a worktree for the task (use git worktree add directly; no need for the runtime layer).
  4. From the worktree directory, dot-sources or invokes task-mark-needs-input's Invoke-TaskMarkNeedsInput.
  5. Asserts the parent's .bot/workspace/tasks/needs-input/<task>.json exists with the right status and pending_question, and that the parent's .bot/workspace/tasks/in-progress/<task>.json no longer exists.

Best home is probably tests/Test-TaskActions.ps1 (already exercises Set-TaskState and friends) with a new --- MCP project root resolves to main repo from worktree --- section. A standalone Test-MCPProjectRoot.ps1 is also fine if the section gets long.

Audit

Sweep all MCP tools to confirm none of them legitimately need worktree-local state:

grep -l 'global:DotbotProjectRoot' core/mcp/tools/*/script.ps1

For any that do (e.g. dev-only tools tied to the worktree's checkout), give them an explicit $WorktreeRoot parameter rather than reading the global.

Secondary findings

These five came out of the same log mining session. Sized for triage; the PR author can decide whether to bundle into the same PR or split. None block the primary fix.

Stale runner lock from operator-killed runner — low / hardening

.bot/.control/launch-task-runner.lock contains the dead PID 45620 after the operator stopped the runner during the privacy-scan blocker. dotbot init -Force later in the same session also kills any active runner. Both are operator-induced rather than graceful exits, so this isn't a production-path bug — but it makes "ctrl-c then start again" require manual lock cleanup.

Suggested hardening: when Set-ProcessLock (or whatever holds the lock during launch) sees an existing lock, check liveness of the recorded PID via Get-Process -Id and reclaim if dead.

Privacy scan blocks task_mark_done on framework-owned files — medium

Activity log at 2026-04-27T13:25:14Z shows task_mark_done blocked with 47 violations: ~32 in agent-editable files (docs/*.md, .bot/workspace/product/briefing/*.md, CLAUDE.md), 8 in .bot/core/mcp/modules/PathSanitizer.psm1 (framework — off-limits per the project CLAUDE.md convention), and 7 elsewhere in the project. The agent fixed the 32 it could touch, then the second task_mark_done blocked at 13:29:06Z listed only the 8 PathSanitizer entries — exactly the files the agent isn't allowed to edit. PR #352 fixes the specific PathSanitizer comments, but the verification step still gives a single pass/fail with no signal that the remaining violations are framework state outside the project's ability to resolve.

Suggested refinement: when 00-privacy-scan.ps1 runs from task_mark_done, partition the result into project_violations (block) and framework_violations (warn + suggest filing a separate dotbot issue) so the project-level task can proceed.

Privacy scan double-counts overlapping pattern matches on the same line — cosmetic

Same 13:25:14Z report shows context-memory.md:287 flagged for both Connection string with password and Secret/password value, and context-memory.md:288 flagged for Private key. Each matching pattern appends a separate violation in core/hooks/verify/00-privacy-scan.ps1 around line 118-133. So one suspicious line counts as two or three.

Suggested: dedupe by (file, line), surface multiple pattern names as a list in the single violation entry. Inflated counts are confusing without changing actual security behaviour.

dotbot init -Force post-install auto-commit fails silently — medium

Reproduced in this session at 2026-04-27T16:00 local:

✓ Installed pre-commit hook
✓ Framework integrity manifest generated
  Committing framework file updates...
⚠ Framework update commit failed — run manually: DOTBOT_FORCE_COMMIT=1 git commit

init -Force leaves the user with a dirty .bot/ tree they have to commit by hand. The actual git error is not surfaced — could be the in-flight pre-commit hook tripping on something, could be no-op, could be a pre-existing dirty tree.

Suggested investigation: read scripts/init-project.ps1 around the post-install commit, capture and surface the actual git stderr. If the cause is "nothing to commit", downgrade to a debug message instead of a warning.

Three todo/ tasks didn't run — unconfirmed / watch-list

.bot/workspace/tasks/todo/generate-decisions-3d825c4e.json
.bot/workspace/tasks/todo/task-group-expansion-6f5a8df1.json
.bot/workspace/tasks/todo/task-groups-b5e519a2.json

None have depends_on. The debug log shows [task-get-next] No eligible tasks found polling every ~5s from 13:31 → 14:01. The user manually stopped the runner around the privacy-scan blocker, so the polling window may simply be "human-input gating" rather than a picker bug. Re-reading core/mcp/tools/task-get-next/script.ps1 and TaskIndexCache.psm1's Get-NextTask showed no obvious filter that would exclude these tasks.

Status: keep this as a watch-list item. After the primary fix lands, re-run the dogfood end-to-end without operator interruption; if the three todo/ tasks still don't pick up, file as a separate bug. Do not claim this is fixed by the primary patch unless the post-fix dogfood confirms it.

Out of scope

These showed up in the logs but are not dotbot framework bugs:

  • Glob head_limit validation error during the analysis phase (Claude tool layer rejecting an unsupported parameter).
  • PowerShell ParserError from a Bash -Command containing backtick-escaped regex examples (agent constructed a syntactically invalid one-liner).
  • Edit: File has not been read yet errors (Claude agent retrying without parsing the prior failure).
  • Long thinking pauses (30s, 83s) in the per-process activity log are model latency, not framework.
  • Context saturation at 100.2% on turn 87 is a Claude window limit; chunking briefing inputs is a bigger architectural change than this issue.

Notes for the PR author

  • One bundled umbrella per PR is the convention; this issue is intentionally self-contained so the PR can Closes #<this> and not need cross-references.
  • Finding 2 is unconfirmed — the post-fix dogfood is the only way to tell. Don't claim it's fixed without that signal.
  • The user prefers no Pester. Stick to imperative Test-*.ps1 scripts via Test-Helpers.psm1 for any new test coverage.
  • PR Fix init prompt paths, unapproved verbs, and analysis loop #352 (fix/init-prompt-paths-verbs-and-loop) is in CI as of issue creation. The primary fix here is independent of Fix init prompt paths, unapproved verbs, and analysis loop #352 — both branches can land in either order.

Verified against current source (2026-04-28)

The fix proposal in this issue is correct. Pinning the line numbers and inlining the audit list so the PR session opens the right files at the right line.

core/mcp/dotbot-mcp.ps1

  • Walk-up loop at lines 31-39.
  • $script:ProjectRoot = $currentPath at line 33.
  • Fatal-exit guard if the walk fails at lines 41-44.
  • $global:DotbotProjectRoot = $script:ProjectRoot at line 47.
  • Invoke-ListTools (the function that returns the tool list to the MCP client) at lines 164-204 (referenced from Pre-load MCP tool schemas at server startup so prompts do not need ToolSearch #366 — orthogonal but worth knowing).

core/runtime/modules/WorktreeManager.psm1

  • Worktree-side restore: git -C $worktreePath checkout -- .bot/workspace/tasks at line 674.
  • Parent-side wipe: git -C $ProjectRoot checkout -- .bot/workspace/tasks/ at line 717.

Audit: scripts under core/mcp/tools/ that read $global:DotbotProjectRoot

37 files match Grep -r 'global:DotbotProjectRoot' core/mcp/tools/. Of these, 33 are production script.ps1 files and 4 are test.ps1 files. None look like they legitimately need a worktree-local view, so the fix applies cleanly across the whole set without per-tool overrides.

Production scripts (33)
  • core/mcp/tools/decision-create/script.ps1
  • core/mcp/tools/decision-get/script.ps1
  • core/mcp/tools/decision-list/script.ps1
  • core/mcp/tools/decision-mark-accepted/script.ps1
  • core/mcp/tools/decision-mark-deprecated/script.ps1
  • core/mcp/tools/decision-mark-superseded/script.ps1
  • core/mcp/tools/decision-update/script.ps1
  • core/mcp/tools/dev-start/script.ps1
  • core/mcp/tools/dev-stop/script.ps1
  • core/mcp/tools/plan-create/script.ps1
  • core/mcp/tools/plan-get/script.ps1
  • core/mcp/tools/plan-update/script.ps1
  • core/mcp/tools/session-get-state/script.ps1
  • core/mcp/tools/session-get-stats/script.ps1
  • core/mcp/tools/session-increment-completed/script.ps1
  • core/mcp/tools/session-initialize/script.ps1
  • core/mcp/tools/session-update/script.ps1
  • core/mcp/tools/steering-heartbeat/script.ps1
  • core/mcp/tools/task-answer-question/script.ps1
  • core/mcp/tools/task-approve-split/script.ps1
  • core/mcp/tools/task-create-bulk/script.ps1
  • core/mcp/tools/task-create/script.ps1
  • core/mcp/tools/task-get-context/script.ps1
  • core/mcp/tools/task-get-next/script.ps1
  • core/mcp/tools/task-get-stats/script.ps1
  • core/mcp/tools/task-list/script.ps1
  • core/mcp/tools/task-mark-analysed/script.ps1
  • core/mcp/tools/task-mark-analysing/script.ps1
  • core/mcp/tools/task-mark-done/script.ps1
  • core/mcp/tools/task-mark-in-progress/script.ps1
  • core/mcp/tools/task-mark-needs-input/script.ps1
  • core/mcp/tools/task-mark-skipped/script.ps1
  • core/mcp/tools/task-mark-todo/script.ps1
Test scripts (4)
  • core/mcp/tools/steering-heartbeat/test.ps1
  • core/mcp/tools/task-answer-question/test.ps1
  • core/mcp/tools/task-mark-done/test.ps1
  • core/mcp/tools/task-mark-skipped/test.ps1

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds-triageNew issue awaiting triage by the steering grouppriority-01Top priority item

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions