diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index c74bc6f70..3d3bcd9b9 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -156,29 +156,44 @@ jobs: cd "$GITHUB_WORKSPACE" done - - name: Wait for npm registry propagation - run: sleep 30 - - name: Verify platform packages are published run: | VERSION=$(node -p "require('./package.json').version") echo "Verifying platform packages at version ${VERSION}..." - FAILED=0 - for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do - PKG="@gsd-build/engine-${platform}" - PUBLISHED=$(npm view "${PKG}@${VERSION}" version 2>/dev/null || echo "") - if [ "${PUBLISHED}" = "${VERSION}" ]; then - echo " ✓ ${PKG}@${VERSION}" - else - echo "::error::${PKG}@${VERSION} not found on npm (got: '${PUBLISHED}')" - FAILED=1 + # Exponential backoff: 5s, 10s, 20s, 30s, 30s (max 5 attempts, ~95s worst case vs fixed 30s + single check) + DELAY=5 + for attempt in $(seq 1 5); do + FAILED=0 + for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do + PKG="@gsd-build/engine-${platform}" + PUBLISHED=$(npm view "${PKG}@${VERSION}" version 2>/dev/null || echo "") + if [ "${PUBLISHED}" != "${VERSION}" ]; then + FAILED=1 + break + fi + done + if [ "${FAILED}" = "0" ]; then + echo "All platform packages verified (attempt ${attempt})." + break fi + if [ "$attempt" = "5" ]; then + echo "::error::One or more platform packages not found after 5 attempts. Aborting." + for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do + PKG="@gsd-build/engine-${platform}" + PUBLISHED=$(npm view "${PKG}@${VERSION}" version 2>/dev/null || echo "") + if [ "${PUBLISHED}" = "${VERSION}" ]; then + echo " ✓ ${PKG}@${VERSION}" + else + echo " ✗ ${PKG}@${VERSION} (got: '${PUBLISHED}')" + fi + done + exit 1 + fi + echo " Attempt ${attempt}: not all packages visible yet, retrying in ${DELAY}s..." + sleep "$DELAY" + DELAY=$((DELAY * 2)) + if [ "$DELAY" -gt 30 ]; then DELAY=30; fi done - if [ "${FAILED}" = "1" ]; then - echo "::error::One or more platform packages are missing from npm. Aborting main package publish to prevent broken installs." - exit 1 - fi - echo "All platform packages verified." - name: Install dependencies run: npm ci @@ -213,28 +228,31 @@ jobs: cd "$TMPDIR" npm init -y > /dev/null 2>&1 - # Wait for npm registry to show the new version (metadata propagation) + # Wait for npm registry with exponential backoff (5s, 10s, 20s, 30s, 30s, 30s, 30s — max ~155s vs fixed 5min) echo "Waiting for gsd-pi@${VERSION} to appear on npm..." - for attempt in $(seq 1 20); do + DELAY=5 + for attempt in $(seq 1 8); do PUBLISHED=$(npm view "gsd-pi@${VERSION}" version 2>/dev/null || echo "") if [ "${PUBLISHED}" = "${VERSION}" ]; then echo " ✓ Version ${VERSION} visible on npm (attempt ${attempt})" break fi - if [ "$attempt" = "20" ]; then - echo "::warning::gsd-pi@${VERSION} not visible on npm after 5 minutes — skipping smoke test" + if [ "$attempt" = "8" ]; then + echo "::warning::gsd-pi@${VERSION} not visible on npm after 8 attempts — skipping smoke test" exit 0 fi - sleep 15 + echo " Attempt ${attempt}: not yet visible, retrying in ${DELAY}s..." + sleep "$DELAY" + DELAY=$((DELAY * 2)) + if [ "$DELAY" -gt 30 ]; then DELAY=30; fi done - # Now install and verify + # Install and verify with backoff (5s, 10s, 20s) echo "Installing gsd-pi@${VERSION}..." + DELAY=5 for attempt in 1 2 3; do if npm install "gsd-pi@${VERSION}" 2>&1 | tee /tmp/install-output.txt; then echo " ✓ Install succeeded" - # Run version check via node directly (npx may resolve wrong binary) - # Strip ANSI escape codes and match version on any line (--version prints a banner) RAW=$(node node_modules/gsd-pi/dist/loader.js --version 2>&1 || echo "FAILED") ACTUAL=$(echo "$RAW" | sed 's/\x1b\[[0-9;]*m//g' | grep -oE "^${VERSION}$" | head -1) if [ "$ACTUAL" = "$VERSION" ]; then @@ -247,9 +265,10 @@ jobs: exit 1 fi fi - echo "Install attempt ${attempt}/3 failed, retrying in 15s..." + echo "Install attempt ${attempt}/3 failed, retrying in ${DELAY}s..." cat /tmp/install-output.txt - sleep 15 + sleep "$DELAY" + DELAY=$((DELAY * 2)) done echo "::error::Smoke test failed — gsd-pi@${VERSION} not installable" exit 1 @@ -259,14 +278,17 @@ jobs: run: | VERSION=$(node -p "require('./package.json').version") echo "Verifying npm dist-tag 'latest' points to ${VERSION}..." - for attempt in $(seq 1 10); do + DELAY=5 + for attempt in $(seq 1 6); do LATEST=$(npm view gsd-pi dist-tags.latest 2>/dev/null || echo "") if [ "${LATEST}" = "${VERSION}" ]; then echo " ✓ npm dist-tags.latest = ${VERSION}" exit 0 fi - echo " Attempt ${attempt}/10: latest=${LATEST}, expected=${VERSION}, retrying in 15s..." - sleep 15 + echo " Attempt ${attempt}/6: latest=${LATEST}, expected=${VERSION}, retrying in ${DELAY}s..." + sleep "$DELAY" + DELAY=$((DELAY * 2)) + if [ "$DELAY" -gt 30 ]; then DELAY=30; fi done echo "::error::dist-tags.latest is '${LATEST}' but expected '${VERSION}' — run: npm dist-tag add gsd-pi@${VERSION} latest" exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ff321f6d..30bfa4a6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,6 @@ on: push: branches: [main] paths-ignore: - - '**.md' - - 'docs/**' - '.github/workflows/ai-triage.yml' - '.github/workflows/build-native.yml' - '.github/workflows/cleanup-dev-versions.yml' @@ -14,8 +12,6 @@ on: pull_request: branches: [main] paths-ignore: - - '**.md' - - 'docs/**' - '.github/workflows/ai-triage.yml' - '.github/workflows/build-native.yml' - '.github/workflows/cleanup-dev-versions.yml' @@ -27,13 +23,60 @@ concurrency: cancel-in-progress: true jobs: - lint: + detect-changes: runs-on: ubuntu-latest + outputs: + docs-only: ${{ steps.check.outputs.docs-only }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Check if only documentation changed + id: check + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} + HEAD_SHA: ${{ github.sha }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + BASE="$PR_BASE_SHA" + else + BASE="$PUSH_BEFORE_SHA" + fi + FILES=$(git diff --name-only "$BASE" "$HEAD_SHA" 2>/dev/null || git diff --name-only HEAD~1) + echo "Changed files:" + echo "$FILES" + NON_DOCS=$(echo "$FILES" | grep -vE '\.(md|markdown)$' | grep -vE '^docs/' | grep -vE '^LICENSE$' || true) + if [ -z "$NON_DOCS" ]; then + echo "docs-only=true" >> "$GITHUB_OUTPUT" + echo "::notice::Only documentation files changed — skipping build/test" + else + echo "docs-only=false" >> "$GITHUB_OUTPUT" + echo "Non-docs files changed:" + echo "$NON_DOCS" + fi + + docs-check: + runs-on: ubuntu-latest + needs: detect-changes + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Scan documentation for prompt injection + run: bash scripts/docs-prompt-injection-scan.sh --diff origin/main + + lint: + needs: detect-changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + - name: Scan for hardcoded secrets run: bash scripts/secret-scan.sh --diff origin/main @@ -53,13 +96,13 @@ jobs: run: node scripts/check-skill-references.mjs build: + needs: detect-changes + if: needs.detect-changes.outputs.docs-only != 'true' runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - with: - fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v6 @@ -70,9 +113,15 @@ jobs: - name: Install dependencies run: npm ci + - name: Install web host dependencies + run: npm --prefix web ci + - name: Build run: npm run build + - name: Build web host + run: npm run build:web-host + - name: Typecheck extensions run: npm run typecheck:extensions @@ -86,14 +135,15 @@ jobs: run: npm run test:integration windows-portability: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: detect-changes + if: >- + needs.detect-changes.outputs.docs-only != 'true' && + github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: windows-latest steps: - name: Checkout repository uses: actions/checkout@v6 - with: - fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 9ca59503e..dc5a48b20 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -36,6 +36,7 @@ jobs: with: node-version: 24 registry-url: https://registry.npmjs.org + cache: 'npm' - name: Install dependencies run: npm ci @@ -78,6 +79,7 @@ jobs: with: node-version: 24 registry-url: https://registry.npmjs.org + cache: 'npm' - name: Install gsd-pi@dev globally run: npm install -g gsd-pi@dev @@ -101,9 +103,10 @@ jobs: npm run test:live-regression - name: Promote to @next - run: npm dist-tag add gsd-pi@${{ needs.dev-publish.outputs.dev-version }} next env: + DEV_VERSION: ${{ needs.dev-publish.outputs.dev-version }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm dist-tag add "gsd-pi@${DEV_VERSION}" next - name: Log in to GHCR uses: docker/login-action@v4 @@ -113,13 +116,15 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push runtime Docker image + env: + DEV_VERSION: ${{ needs.dev-publish.outputs.dev-version }} run: | docker build --target runtime \ - -t ghcr.io/gsd-build/gsd-pi:next \ - -t ghcr.io/gsd-build/gsd-pi:${{ needs.dev-publish.outputs.dev-version }} \ + -t "ghcr.io/gsd-build/gsd-pi:next" \ + -t "ghcr.io/gsd-build/gsd-pi:${DEV_VERSION}" \ . docker push ghcr.io/gsd-build/gsd-pi:next - docker push ghcr.io/gsd-build/gsd-pi:${{ needs.dev-publish.outputs.dev-version }} + docker push "ghcr.io/gsd-build/gsd-pi:${DEV_VERSION}" prod-release: name: Production Release @@ -136,6 +141,7 @@ jobs: with: node-version: 24 registry-url: https://registry.npmjs.org + cache: 'npm' - name: Install dependencies run: npm ci @@ -158,44 +164,50 @@ jobs: echo "$OUTPUT" | jq -r '.releaseNotes' > /tmp/release-notes.md - name: Bump version and sync packages - run: node scripts/bump-version.mjs "${{ steps.release.outputs.version }}" + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: node scripts/bump-version.mjs "$RELEASE_VERSION" - name: Update CHANGELOG.md run: node scripts/update-changelog.mjs /tmp/changelog-entry.md - name: Commit, tag, and push + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add package.json package-lock.json CHANGELOG.md native/npm/*/package.json pkg/package.json packages/pi-coding-agent/package.json - git commit -m "release: v${{ steps.release.outputs.version }}" - git tag "v${{ steps.release.outputs.version }}" + git commit -m "release: v${RELEASE_VERSION}" + git tag "v${RELEASE_VERSION}" git push origin main - git push origin "v${{ steps.release.outputs.version }}" + git push origin "v${RELEASE_VERSION}" - name: Build release run: npm run build - name: Publish release to npm @latest + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} run: | OUTPUT=$(npm publish 2>&1) && echo "$OUTPUT" || { if echo "$OUTPUT" | grep -q "cannot publish over the previously published"; then echo "Version already published — promoting to latest" - npm dist-tag add gsd-pi@${{ steps.release.outputs.version }} latest + npm dist-tag add "gsd-pi@${RELEASE_VERSION}" latest else echo "$OUTPUT" exit 1 fi } - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} run: | - gh release create "v${{ steps.release.outputs.version }}" \ - --title "v${{ steps.release.outputs.version }}" \ + gh release create "v${RELEASE_VERSION}" \ + --title "v${RELEASE_VERSION}" \ --notes-file /tmp/release-notes.md \ --latest @@ -203,12 +215,12 @@ jobs: if: ${{ env.DISCORD_WEBHOOK != '' }} env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} run: | - VERSION="${{ steps.release.outputs.version }}" NOTES=$(cat /tmp/release-notes.md) curl -s -X POST "$DISCORD_WEBHOOK" \ -H "Content-Type: application/json" \ - -d "$(jq -n --arg c "**GSD v${VERSION} Released**\n\n${NOTES}\n\n\`npm i gsd-pi@${VERSION}\`" '{content:$c}')" + -d "$(jq -n --arg c "**GSD v${RELEASE_VERSION} Released**\n\n${NOTES}\n\n\`npm i gsd-pi@${RELEASE_VERSION}\`" '{content:$c}')" - name: Log in to GHCR uses: docker/login-action@v4 @@ -218,9 +230,11 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Tag runtime Docker image as latest + env: + DEV_VERSION: ${{ needs.dev-publish.outputs.dev-version }} run: | - docker pull ghcr.io/gsd-build/gsd-pi:${{ needs.dev-publish.outputs.dev-version }} - docker tag ghcr.io/gsd-build/gsd-pi:${{ needs.dev-publish.outputs.dev-version }} ghcr.io/gsd-build/gsd-pi:latest + docker pull "ghcr.io/gsd-build/gsd-pi:${DEV_VERSION}" + docker tag "ghcr.io/gsd-build/gsd-pi:${DEV_VERSION}" ghcr.io/gsd-build/gsd-pi:latest docker push ghcr.io/gsd-build/gsd-pi:latest update-builder: @@ -229,12 +243,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 2 - name: Check for Dockerfile changes id: check + env: + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} run: | - CHANGED=$(git diff --name-only ${{ github.event.workflow_run.head_sha }}~1 ${{ github.event.workflow_run.head_sha }} -- Dockerfile || echo "") - echo "changed=$([[ -n \"$CHANGED\" ]] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT" + CHANGED=$(git diff --name-only "${HEAD_SHA}~1" "${HEAD_SHA}" -- Dockerfile || echo "") + echo "changed=$([[ -n "$CHANGED" ]] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT" - name: Log in to GHCR if: steps.check.outputs.changed == 'true' diff --git a/.github/workflows/pr-risk.yml b/.github/workflows/pr-risk.yml new file mode 100644 index 000000000..bde087b7a --- /dev/null +++ b/.github/workflows/pr-risk.yml @@ -0,0 +1,73 @@ +name: PR Risk Report + +# pull_request_target runs in the base repo context so the token has +# pull-requests: write even for cross-fork PRs. We never execute code +# from the fork — changed files are fetched via the GitHub API only. +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + risk-check: + name: Classify changed files and assess risk + runs-on: ubuntu-latest + + steps: + # Checkout the BASE branch — our trusted script and map, not fork code. + - name: Checkout base + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + # Use the GitHub API to get changed files — no fork code is executed. + - name: Get changed files + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api \ + repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files \ + --paginate \ + --jq '.[].filename' > /tmp/changed-files.txt + echo "Changed files:" + cat /tmp/changed-files.txt + + - name: Run risk check + id: risk + run: | + REPORT=$(cat /tmp/changed-files.txt | node scripts/pr-risk-check.mjs --github || true) + echo "report<> $GITHUB_OUTPUT + echo "$REPORT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + RISK_LEVEL=$(cat /tmp/changed-files.txt | node scripts/pr-risk-check.mjs --json 2>/dev/null \ + | node -e "let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ try { console.log(JSON.parse(d).risk) } catch { console.log('low') } })" \ + || echo "low") + echo "level=$RISK_LEVEL" >> $GITHUB_OUTPUT + + - name: Write step summary + run: echo "${{ steps.risk.outputs.report }}" >> $GITHUB_STEP_SUMMARY + + - name: Find existing risk comment + id: find-comment + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: github-actions[bot] + body-includes: PR Risk Report + + - name: Post or update risk comment + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: ${{ steps.risk.outputs.report }} + edit-mode: replace diff --git a/.gitignore b/.gitignore index 11d0ea16d..465c44380 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ package-lock.json .claude/ RELEASE-GUIDE.md *.tgz +*.tsbuildinfo .DS_Store Thumbs.db *.swp @@ -58,3 +59,6 @@ docs/coherence-audit/ # ── Stale lock files (npm is canonical) ── pnpm-lock.yaml bun.lock + +# ── GSD baseline (auto-generated) ── +.gsd diff --git a/.secretscanignore b/.secretscanignore index 6c08b9a7e..f81ab4813 100644 --- a/.secretscanignore +++ b/.secretscanignore @@ -17,9 +17,15 @@ tests/*:AKIA_EXAMPLE tests/*:test-secret-value tests/*:fake[-_]?(password|secret|token|key) +# Web contract/integration test dummy API keys (not real secrets) +src/tests/integration/web-mode-assembled.test.ts:sk-assembled-test-key +src/tests/integration/web-mode-runtime-fixtures.ts:sk-runtime-recovery-secret +src/tests/web-onboarding-contract.test.ts:sk-test-secret + # Doctor environment tests use dummy localhost DB URLs src/resources/extensions/gsd/tests/doctor-environment.test.ts:postgres://localhost + # Documentation examples *.md:AKIA[0-9A-Z]{16} *.md:sk_(live|test)_ diff --git a/CHANGELOG.md b/CHANGELOG.md index 913e5fe94..b67679841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,110 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.41.0] - 2026-03-21 + +### Added +- **doctor**: worktree lifecycle checks, cleanup consolidation, enhanced /worktree list (#1814) +- **web**: browser-based web interface (#1717) +- **ci**: skip build/test for docs-only PRs and add prompt injection scan (#1699) +- **docs**: add Custom Models guide and update related documentation (#1670) +- surface doctor issue details in progress score widget and health views (#1667) +- **cleanup**: add ~/.gsd/projects/ orphan detection and pruning (#1686) + +### Fixed +- skip web build on Windows — Next.js webpack hits EPERM on system dirs +- include web build in main build command +- fall through to prose slice parser when checkbox parser yields empty under ## Slices (#1744) +- **auto**: verify merge anchored before worktree teardown (#1829) +- **auto**: reject execute-task with zero tool calls as hallucinated (#1838) +- also convert --import resolver path to file URL for Windows +- use pathToFileURL for Windows-safe ESM import in verification-gate test +- **gsd**: read depends_on from CONTEXT-DRAFT.md when CONTEXT.md is absent (#1743) +- **roadmap**: detect ✓ completion marker in prose slice headers (#1816) +- **auto**: reverse-sync root-level .gsd files on worktree teardown (#1831) +- **tui**: prevent freeze when using @ file finder (#1832) +- prevent silent data loss when milestone merge fails due to dirty working tree (#1752) +- **verification**: avoid DEP0190 by passing command to shell explicitly (#1827) +- **state**: treat zero-slice roadmap as pre-planning instead of blocked (#1826) +- **hooks**: process depth verification in queue mode (#1823) +- **auto**: register SIGHUP/SIGINT handlers to clean lock files on crash (#1821) +- auto-dispatch discussion instead of hard-stopping on needs-discussion phase (#1820) +- **doctor**: fix roadmap checkbox and UAT stub immediately instead of deferring (#1819) +- **auto**: resolve pending unitPromise in stopAuto to prevent hang (#1818) +- **git**: handle unborn branch in nativeBranchExists to prevent dispatch deadlock (#1815) +- **doctor**: prevent cleanup from deleting user work files (#1825) +- use realpathSync.native on Windows to resolve 8.3 short paths +- detect and skip ghost milestone directories in deriveState() (#1817) +- create milestone directory when triage defers to a not-yet-existing milestone (#1813) +- add @gsd/pi-tui to test module resolver in dist-redirect (#1811) +- surface unmapped active requirements when all milestones complete (#1805) +- normalize paths in tests to handle Windows 8.3 short-path forms (#1804) +- share milestone ID reservation between preview and tool (#1569) (#1802) +- **tui,gsd**: tool-call loop guard + TUI stack overflow prevention (#1801) +- validate paused-session milestone before restoring it (#1664) (#1800) +- detect REPLAN-TRIGGER.md in deriveState for triage-initiated replans (#1798) +- dispatch uat targets last completed slice instead of activeSlice (#1693) (#1796) +- read depends_on from CONTEXT-DRAFT.md when CONTEXT.md absent (#1795) +- **worktree**: sync root-level files and all milestone dirs on worktree teardown (#1794) +- dashboard highlights UAT target slice instead of advanced activeSlice (#1793) +- dispatch guard skips completed milestones with SUMMARY file (#1791) +- ensureDbOpen creates DB + migrates Markdown in interactive sessions (#1790) +- add require condition to pi-tui exports for CJS resolution +- update integration test to match dependency-aware dispatch guard wording +- use createRequire instead of bare require for lazy pi-tui import +- update doctor-git test to match PR #1633 behavior change +- increase resolveProjectRootFromGitFile walk-up limit from 10 to 30 +- include ensure-workspace-builds.cjs in npm package files +- resolve extension typecheck errors in test files +- resolve CI build errors from Wave 4+5 merges +- return retry from postUnitPreVerification when artifact verification fails (#1571) (#1782) +- hook model field uses model-router resolution instead of Claude-only registry (#1720) (#1781) +- stop auto-mode immediately on infrastructure errors (ENOSPC, ENOMEM, etc.) (#1780) +- add missing milestones/ segment in resolveHookArtifactPath (#1779) +- break needs-discussion infinite loop when survivor branch exists (#1726) (#1778) +- tear down browser sessions at unit boundaries and in stopAuto (#1733) (#1777) +- rebuild STATE.md and reset completed-units on milestone transition (#1576) (#1775) +- resolve pending unit promise on all exit paths to prevent orphaned auto-loop (#1774) +- closeout unit on pause and heal runtime records on resume (#1625) (#1773) +- call selfHealRuntimeRecords before autoLoop to clear orphaned dispatched records (#1772) +- dispatch guard uses dependency declarations instead of positional ordering (#1638) (#1770) +- add configurable timeout to await_job to prevent indefinite session blocking (#1769) +- **parallel**: restore orchestrator state from session files and add worker stderr logging (#1748) +- prevent getLoadedSkills crash and auto-build workspace packages (#1767) +- session lock multi-path cleanup and false positive hardening (#1578) (#1765) +- robust node_modules symlink handling to prevent extension loading failures (#1762) +- lazy-load @gsd/pi-tui in shared/ui.ts to prevent /exit crash (#1761) +- validate worktree .git file and fix metrics toolCall casing (#1713) (#1754) +- verify implementation artifacts before milestone completion (#1703) (#1760) +- make task closeout crash-safe by unchecking orphaned checkboxes (#1650) (#1759) +- preserve milestone branch on merge-back during transitions (#1573) (#1758) +- write crash lock after newSession so it records correct session path (#1757) +- handle symlinked .gsd in git add pathspec exclusions (#1712) (#1756) +- guard worktree teardown on empty merge to prevent data loss (#1672) (#1755) +- resolve symlinks in doctor orphaned-worktree check (#1715) (#1753) +- silence spurious extension load error for non-extension libraries (#1709) (#1747) +- reset completion state when post_unit_hooks retry_on signal is consumed (#1746) +- route needs-discussion phase to showSmartEntry, preventing infinite /gsd loop (#1745) +- **roadmap**: parse table-format slices in roadmap files (#1741) +- extract milestone title from CONTEXT.md when ROADMAP is missing (#1729) +- **gsd**: harden auto-mode telemetry — metrics idempotency, elapsed guard, title sanitization (#1722) +- **gsd**: make saveJsonFile atomic via write-tmp-rename pattern (#1719) +- **gsd**: syncWorktreeStateBack recurses into tasks/ subdirectory (#1678) (#1718) +- prevent parallel worktree path resolution from escaping to home directory (#1677) +- add web search budget awareness to discuss and queue prompts (#1702) +- harden auto-mode against stale integration metadata and Windows file locks (#1633) +- **autocomplete**: repair /gsd skip, add widget/next completions, add discuss to hint (#1675) +- **search**: keep loop guard armed after firing to prevent infinite loop restart (#1671) (#1674) +- **worktree**: detect default branch instead of hardcoding "main" on milestone merge (#1668) (#1669) +- remove duplicate TUI header rendered on session_start (#1663) +- **worktree**: recurse into tasks/ when syncing slice artifacts back to project root (#1678) (#1681) + +### Changed +- split shared/mod.ts into pure and TUI-dependent barrels (#1807) +- replace hardcoded /tmp paths with os.tmpdir()/homedir() (#1708) +- **ci**: reduce pipeline minutes with shallow clones, npm caching, and exponential backoff (#1700) +- split auto-loop.ts monolith into auto/ directory modules (#1682) + ## [2.40.0] - 2026-03-20 ### Added @@ -239,6 +343,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - prevent false-positive 'Session lock lost' during auto-mode (#1257) + ## [2.31.0] - 2026-03-18 ### Added @@ -1493,7 +1598,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - License updated to MIT -[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.40.0...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.41.0...HEAD +[2.41.0]: https://github.com/gsd-build/gsd-2/compare/v2.40.0...v2.41.0 [2.40.0]: https://github.com/gsd-build/gsd-2/compare/v2.39.0...v2.40.0 [2.39.0]: https://github.com/gsd-build/gsd-2/compare/v2.38.0...v2.39.0 [2.38.0]: https://github.com/gsd-build/gsd-2/compare/v2.37.1...v2.38.0 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..a99d49433 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,172 @@ +# Doctor + Cleanup Consolidation Plan + +## Problem + +GSD has 7+ commands that check, diagnose, or clean up project state. Several overlap or duplicate each other, and worktree lifecycle management is missing entirely. Users can't answer "what's safe to delete?" without manual git investigation. + +### Current surface area + +| Command | Purpose | Overlap | +|---|---|---| +| `/gsd doctor` | State integrity, git health, worktrees, runtime, env, prefs | **Primary health system** | +| `/gsd doctor fix` | Auto-fix detected issues | | +| `/gsd doctor heal` | Dispatch unfixable issues to LLM | | +| `/gsd doctor audit` | Expanded output, no fix | | +| `/gsd cleanup` | Runs branches + snapshots cleanup | **Redundant** — doctor already handles branches | +| `/gsd cleanup branches` | Delete merged `gsd/*` branches | **Redundant** — doctor detects but won't fix legacy branches | +| `/gsd cleanup snapshots` | Prune old snapshot refs | **Gap** — doctor has no snapshot check | +| `/gsd cleanup projects` | Audit orphaned `~/.gsd/projects/` dirs | **Fully redundant** — doctor's `orphaned_project_state` does the same | +| `/gsd keys doctor` | Per-key health check | **Complementary** — deeper than doctor's surface provider check | +| `/gsd skill-health` | Skill usage stats | No overlap — analytics, not health | +| `/gsd inspect` | SQLite DB diagnostics | No overlap — introspection tool | +| `/gsd forensics` | Post-failure investigation | No overlap — different lifecycle | + +### Missing + +- No worktree lifecycle checks (merged? stale? dirty? unpushed?) +- `/worktree list` shows name/branch/path but no safety status +- Doctor checks completed-milestone worktrees but nothing else + +--- + +## Design: Doctor as the single health authority + +**Principle:** Doctor finds problems. Doctor fix resolves them. One command, not three paths to the same outcome. + +### Phase 1: New doctor checks for worktree lifecycle + +Add to `doctor-checks.ts` → `checkGitHealth()`: + +| Check code | Severity | Fixable | Condition | What `--fix` does | +|---|---|---|---|---| +| `worktree_branch_merged` | info | yes | Worktree's branch is fully merged into main (merge-base --is-ancestor) | Remove worktree + delete branch | +| `worktree_stale` | warning | no | No commits in 14+ days AND no open PR on remote | Report only — needs user decision | +| `worktree_dirty` | warning | no | Stale worktree has uncommitted changes | Report only — data loss risk | +| `worktree_unpushed` | warning | no | Worktree branch has commits not on any remote | Report only — push first | + +**Scope:** Only GSD-managed worktrees under `.gsd/worktrees/`. Not `.claude/worktrees/`, not sibling repos, not `/tmp/` worktrees. GSD owns what GSD creates. + +**Safety rules:** +- Never auto-remove a worktree matching `process.cwd()` (existing pattern) +- Never auto-remove a worktree with uncommitted changes +- Never auto-remove a worktree with unpushed commits +- `worktree_branch_merged` is the only auto-fixable worktree check — it's the safest (work is already in main) + +### Phase 2: Fold `/gsd cleanup` into doctor + +**2a. Make `legacy_slice_branches` fixable in doctor.** + +Currently detected as `info` severity, not fixable. Change to: +- Severity: `info` (keep) +- Fixable: `true` +- `--fix` action: `nativeBranchDelete(basePath, branch, true)` for each merged legacy branch + +This makes `cleanup branches` redundant — doctor handles both `milestone/*` and `gsd/*` branches. + +**2b. Add `snapshot_ref_bloat` doctor check.** + +New check in `checkRuntimeHealth()`: +- Count `refs/gsd/snapshots/` refs +- If > 50 refs per label, report `snapshot_ref_bloat` (warning, fixable) +- `--fix` action: prune to newest 5 per label (same logic as existing `handleCleanupSnapshots`) + +This makes `cleanup snapshots` redundant. + +**2c. `/gsd cleanup projects` is already redundant.** + +Doctor's `orphaned_project_state` check (in `checkGlobalHealth`) does the same thing. No code change needed — just deprecation. + +**2d. `/gsd cleanup` becomes a permanent alias.** + +- `/gsd cleanup` → runs `doctor fix` scoped to cleanup-class issues (branches, snapshots, projects, worktrees) +- `/gsd cleanup branches` → doctor fix for branch issues +- `/gsd cleanup snapshots` → doctor fix for snapshot issues +- `/gsd cleanup projects` → doctor fix for project state issues +- `/gsd cleanup worktrees` → doctor fix for worktree issues + +No deprecation warnings. Same commands, doctor under the hood. Existing muscle memory keeps working. + +### Phase 3: Enhance `/worktree list` with safety status + +Enhance `handleList()` in `worktree-command.ts` to show safety information inline: + +``` +GSD Worktrees + + feature-x ● active + branch worktree/feature-x + path .gsd/worktrees/feature-x + status 3 uncommitted files · 2 unpushed commits · last commit 4h ago + + old-bugfix + branch worktree/old-bugfix + path .gsd/worktrees/old-bugfix + status ✓ merged into main · safe to remove + + stale-experiment + branch worktree/stale-experiment + path .gsd/worktrees/stale-experiment + status ⚠ no commits in 18 days · no open PR +``` + +Data to show per worktree: +- Uncommitted file count (if any) +- Unpushed commit count (if any) +- Merge status (merged into main or not) +- Last commit age +- Whether branch has been pushed to remote + +### Phase 4: Add `/gsd cleanup worktrees` convenience entry point + +For discoverability, add to the cleanup catalog: +``` +/gsd cleanup worktrees — Remove merged/safe-to-delete worktrees +/gsd cleanup worktrees --dry — Preview what would be removed +``` + +This is a thin wrapper that runs doctor fix scoped to `worktree_branch_merged` issues only. + +--- + +## What stays separate (no changes) + +| Command | Why | +|---|---| +| `/gsd keys doctor` | Deeper per-key analysis; general doctor's provider check is a sufficient surface check | +| `/gsd inspect` | DB introspection — not a health check | +| `/gsd skill-health` | Usage analytics — not a health check | +| `/gsd forensics` | Post-mortem investigation — different purpose and lifecycle | +| `/gsd logs` | Read-only log viewer | + +--- + +## Implementation order + +1. **Phase 1** — Worktree lifecycle checks in doctor (the core ask) +2. **Phase 3** — Enhanced `/worktree list` (immediate user value, depends on same data as Phase 1) +3. **Phase 2** — Fold cleanup into doctor (reduces surface area) +4. **Phase 4** — Cleanup worktrees convenience entry (trivial once Phase 1+2 land) + +Phase 1 and 3 share git inspection code (merge status, uncommitted changes, unpushed commits). Build that as shared helpers in `worktree-manager.ts` or a new `worktree-health.ts`, then both phases consume it. + +--- + +## Files likely touched + +| File | Changes | +|---|---| +| `doctor-checks.ts` | New worktree lifecycle checks, make `legacy_slice_branches` fixable, add snapshot bloat check | +| `doctor-types.ts` | New issue codes: `worktree_branch_merged`, `worktree_stale`, `worktree_dirty`, `worktree_unpushed`, `snapshot_ref_bloat` | +| `worktree-manager.ts` | New helpers: `getWorktreeMergeStatus()`, `getWorktreeDirtyStatus()`, `getWorktreeUnpushedCount()`, `getWorktreeLastCommitAge()` | +| `worktree-command.ts` | Enhanced `handleList()` with safety status | +| `commands-maintenance.ts` | Deprecation wrappers for cleanup subcommands | +| `commands/catalog.ts` | Add `worktrees` to cleanup subcommands, update doctor subcommand descriptions | +| `commands/handlers/ops.ts` | Wire up `/gsd cleanup worktrees` | + +--- + +## Decisions + +1. **Stale threshold** — 14 days default, configurable via preferences. +2. **Remote PR check** — Commit age is the primary signal. PR check is a bonus when `gh` is available. Degrade gracefully if `gh` is missing. +3. **Cleanup as permanent alias** — `/gsd cleanup` stays as a permanent alias that silently calls doctor fix under the hood. No deprecation noise. Users who learned cleanup keep using it, new users learn doctor. diff --git a/README.md b/README.md index 33e29d038..726ed21e1 100644 --- a/README.md +++ b/README.md @@ -24,25 +24,75 @@ One command. Walk away. Come back to a built project with clean git history. --- -## What's New in v2.38 +## What's New in v2.41.0 -- **Reactive task execution (ADR-004)** — graph-derived parallel task dispatch within slices. When enabled, GSD derives a dependency graph from IO annotations in task plans and dispatches multiple non-conflicting tasks in parallel via subagents. Backward compatible — disabled by default. Enable with `reactive_execution: true` in preferences. -- **Anthropic Vertex AI provider** — run Claude models (Opus 4.6, Sonnet 4.6, Haiku 4.5) through Google Vertex AI. Set `ANTHROPIC_VERTEX_PROJECT_ID` to activate. -- **CI optimization** — GitHub Actions minutes reduced ~60-70% (~10k → ~3-4k/month) -- **Reactive batch verification** — dependency-based carry-forward for verification results across parallel task batches -- **Backtick file path enforcement** — task plan IO sections now require backtick-wrapped paths for reliable parsing +### New Features -See the full [Changelog](./CHANGELOG.md) for details. +- **Browser-based web interface** — run GSD from the browser with `pi --web`. Full project management, real-time progress, and multi-project support via server-sent events. (#1717) +- **Doctor: worktree lifecycle checks** — `/gsd doctor` now validates worktree health, detects orphaned worktrees, consolidates cleanup, and enhances `/worktree list` with lifecycle status. (#1814) +- **CI: docs-only PR detection** — PRs that only change documentation skip build and test steps, with a new prompt injection scan for security. (#1699) +- **Custom Models guide** — new documentation for adding custom providers (Ollama, vLLM, LM Studio, proxies) via `models.json`. (#1670) -### Previous highlights (v2.34–v2.37) +### Data Loss Prevention (Critical Fixes) -- **cmux integration** — sidebar status, progress bars, and notifications for cmux terminal multiplexer users -- **Redesigned dashboard** — two-column layout with 4 widget modes (full → small → min → off) -- **AGENTS.md support** — deprecated `agent-instructions.md` in favor of standard `AGENTS.md` / `CLAUDE.md` -- **AI-powered triage** — automated issue and PR triage via Claude Haiku -- **Auto-generated OpenRouter registry** — model registry built from OpenRouter API -- **`/gsd changelog`** — LLM-summarized release notes for any version -- **Search budget enforcement** — session-level cap prevents unbounded web search +This release includes 7 fixes preventing silent data loss in auto-mode: + +- **Hallucination guard** — execute-task agents that complete with zero tool calls are now rejected as hallucinated. Previously, agents could produce detailed but fabricated summaries without writing any code, wasting ~$25/milestone. (#1838) +- **Merge anchor verification** — before deleting a milestone worktree/branch, GSD now verifies the code is actually on the integration branch. Prevents orphaning commits when squash-merge produces an empty diff. (#1829) +- **Dirty working tree detection** — `nativeMergeSquash` now distinguishes dirty-tree rejections from content conflicts, preventing silent commit loss when synced `.gsd/` files block the merge. (#1752) +- **Doctor cleanup safety** — the `orphaned_completed_units` check no longer auto-fixes during post-task health checks. Previously, timing races could cause the doctor to remove valid completion keys, reverting users to earlier tasks. (#1825) +- **Root file reverse-sync** — worktree teardown now syncs root-level `.gsd/` files (PROJECT.md, REQUIREMENTS.md, completed-units.json) back to the project root. Previously these were lost on milestone closeout. (#1831) +- **Empty merge guard** — milestone branches with unanchored code changes are preserved instead of deleted when squash-merge produces nothing to commit. (#1755) +- **Crash-safe task closeout** — orphaned checkboxes in PLAN.md are unchecked on retry, preventing phantom task completion. (#1759) + +### Auto-Mode Stability + +- **Terminal hang fix** — `stopAuto()` now resolves pending promises, preventing the terminal from freezing permanently after stopping auto-mode. (#1818) +- **Signal handler coverage** — SIGHUP and SIGINT now clean up lock files, not just SIGTERM. Prevents stranded locks on VS-Code crash. (#1821) +- **Needs-discussion routing** — milestones in `needs-discussion` phase now route to the smart entry UI instead of hard-stopping, breaking the infinite loop. (#1820) +- **Infrastructure error handling** — auto-mode stops immediately on ENOSPC, ENOMEM, and similar unrecoverable errors instead of retrying. (#1780) +- **Dependency-aware dispatch** — slice dispatch now uses declared `depends_on` instead of positional ordering. (#1770) +- **Queue mode depth verification** — the write gate now processes depth verification in queue mode, fixing a deadlock where CONTEXT.md writes were permanently blocked. (#1823) + +### Roadmap Parser Improvements + +- **Table format support** — roadmaps using markdown tables (`| S01 | Title | Risk | Status |`) are now parsed correctly. (#1741) +- **Prose header fallback** — when `## Slices` contains H3 headers instead of checkboxes, the prose parser is invoked as a fallback. (#1744) +- **Completion marker detection** — prose headers with `✓` or `(Complete)` markers are correctly identified as done. (#1816) +- **Zero-slice stub handling** — stub roadmaps from `/gsd queue` return `pre-planning` instead of `blocked`. (#1826) +- **Immediate roadmap fix** — roadmap checkbox and UAT stub are fixed immediately after last task instead of deferring to `complete-slice`. (#1819) + +### State & Git Improvements + +- **CONTEXT-DRAFT.md fallback** — `depends_on` is read from CONTEXT-DRAFT.md when CONTEXT.md doesn't exist, preventing draft milestones from being promoted past dependency constraints. (#1743) +- **Unborn branch support** — `nativeBranchExists` handles repos with zero commits, preventing dispatch deadlock on new repos. (#1815) +- **Ghost milestone detection** — empty `.gsd/milestones/` directories are skipped instead of crashing `deriveState()`. (#1817) +- **Default branch detection** — milestone merge detects `master` vs `main` instead of hardcoding. (#1669) +- **Milestone title extraction** — titles are pulled from CONTEXT.md headings when no ROADMAP exists. (#1729) + +### Windows & Platform + +- **Windows path handling** — 8.3 short paths, `pathToFileURL` for ESM imports, and `realpathSync.native` fixes across the test suite and verification gate. (#1804) +- **DEP0190 fix** — `spawnSync` deprecation warning eliminated by passing commands to shell explicitly. (#1827) +- **Web build skip on Windows** — Next.js webpack EPERM errors on system directories are handled gracefully. + +### Developer Experience + +- **@ file finder fix** — typing `@` no longer freezes the TUI. The fix adds debounce, dedup, and empty-query short-circuit. (#1832) +- **Tool-call loop guard** — detects and breaks infinite tool-call loops within a single unit, preventing stack overflow. (#1801) +- **Completion deferral fix** — roadmap checkbox and UAT stub are fixed at task level, closing the fragile handoff window between last task and `complete-slice`. (#1819) + +See the full [Changelog](./CHANGELOG.md) for all 70+ fixes in this release. + +### Previous highlights (v2.39–v2.40) + +- **GitHub sync extension** — auto-sync milestones to GitHub Issues, PRs, and Milestones +- **Skill tool resolution** — skills auto-activate in dispatched prompts +- **Health check phase 2** — real-time doctor issues in dashboard and visualizer +- **Forensics upgrade** — full-access GSD debugger with anomaly detection +- **Pipeline decomposition** — auto-loop rewritten as linear phase pipeline +- **Sliding-window stuck detection** — pattern-aware, fewer false positives +- **Data-loss recovery** — automatic detection and recovery from v2.30–v2.38 migration issues --- @@ -53,6 +103,7 @@ Full documentation is available in the [`docs/`](./docs/) directory: - **[Getting Started](./docs/getting-started.md)** — install, first run, basic usage - **[Auto Mode](./docs/auto-mode.md)** — autonomous execution deep-dive - **[Configuration](./docs/configuration.md)** — all preferences, models, git, and hooks +- **[Custom Models](./docs/custom-models.md)** — add custom providers (Ollama, vLLM, LM Studio, proxies) - **[Token Optimization](./docs/token-optimization.md)** — profiles, context compression, complexity routing - **[Cost Management](./docs/cost-management.md)** — budgets, tracking, projections - **[Git Strategy](./docs/git-strategy.md)** — worktree isolation, branching, merge behavior @@ -173,7 +224,7 @@ Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`, 5. **Provider error recovery** — Transient provider errors (rate limits, 500/503 server errors, overloaded) auto-resume after a delay. Permanent errors (auth, billing) pause for manual review. The model fallback chain retries transient network errors before switching models. -6. **Stuck detection** — If the same unit dispatches twice (the LLM didn't produce the expected artifact), it retries once with a deep diagnostic. If it fails again, auto mode stops with the exact file it expected. +6. **Stuck detection** — A sliding-window detector identifies repeated dispatch patterns (including multi-unit cycles). On detection, it retries once with a deep diagnostic. If it fails again, auto mode stops with the exact file it expected. 7. **Timeout supervision** — Soft timeout warns the LLM to wrap up. Idle watchdog detects stalls. Hard timeout pauses auto mode. Recovery steering nudges the LLM to finish durable output before giving up. @@ -309,9 +360,9 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro | `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format | | `/gsd help` | Categorized command reference for all GSD subcommands | | `/gsd mode` | Switch workflow mode (solo/team) with coordinated defaults | -| `/gsd forensics` | Post-mortem investigation of auto-mode failures | +| `/gsd forensics` | Full-access GSD debugger for auto-mode failure investigation | | `/gsd cleanup` | Archive phase directories from completed milestones | -| `/gsd doctor` | Runtime health checks with auto-fix for common issues | +| `/gsd doctor` | Runtime health checks — issues surface across widget, visualizer, and reports | | `/gsd keys` | API key manager — list, add, remove, test, rotate, doctor | | `/gsd logs` | Browse activity, debug, and metrics logs | | `/gsd export --html` | Generate HTML report for current or completed milestone | @@ -344,6 +395,8 @@ Every dispatch is carefully constructed. The LLM never wastes tool calls on orie | ------------------ | --------------------------------------------------------------- | | `PROJECT.md` | Living doc — what the project is right now | | `DECISIONS.md` | Append-only register of architectural decisions | +| `KNOWLEDGE.md` | Cross-session rules, patterns, and lessons learned | +| `RUNTIME.md` | Runtime context — API endpoints, env vars, services (v2.39) | | `STATE.md` | Quick-glance dashboard — always read first | | `M001-ROADMAP.md` | Milestone plan with slice checkboxes, risk levels, dependencies | | `M001-CONTEXT.md` | User decisions from the discuss phase | diff --git a/docs/FILE-SYSTEM-MAP.md b/docs/FILE-SYSTEM-MAP.md new file mode 100644 index 000000000..cfaa65fae --- /dev/null +++ b/docs/FILE-SYSTEM-MAP.md @@ -0,0 +1,1020 @@ +# GSD2 File System Map +# Maps every source file to its system/subsystem labels + +--- + +## System Labels Reference + +| Label | Description | +|-------|-------------| +| **Agent Core** | Core agent loop, session lifecycle, SDK factory | +| **AI Providers** | LLM provider implementations (Anthropic, OpenAI, Google, etc.) | +| **API Routes** | Next.js API route handlers (web server) | +| **AST** | Abstract Syntax Tree search/rewrite via tree-sitter + ast-grep | +| **Async Jobs** | Background bash job management | +| **Auth / OAuth** | Authentication, OAuth flows, token storage | +| **Auto Engine** | GSD autonomous execution loop, dispatch, supervision | +| **Bg Shell** | Background process / interactive shell management | +| **Browser Tools** | Playwright-based browser automation extension | +| **Build System** | Scripts for build, packaging, version management, CI | +| **CLI** | Command-line entry points and argument parsing | +| **CMux** | Tmux/multiplexer session integration | +| **Commands** | GSD slash/sub-command routing and handlers | +| **Compaction** | Context token reduction and summarization | +| **Config** | Paths, defaults, models, preferences, constants | +| **Context7** | Library documentation fetching extension | +| **Doctor / Diagnostics** | Health checks, forensics, skill health | +| **Event System** | Event bus, publication/subscription | +| **Extension Registry** | Extension discovery, manifests, enable/disable | +| **Extensions** | Extension loader, runner, project trust, hooks | +| **File Search** | grep, glob, fd — file and content discovery | +| **GSD Workflow** | Core GSD planning/execution workflow engine | +| **Google Search** | Web search via Google API | +| **Headless Mode** | Non-interactive / scripted command execution | +| **Image Processing** | Image decode, resize, encode, clipboard images | +| **Integration Tests** | Smoke, fixture, live, regression test suites | +| **Loader / Bootstrap** | Startup initialization, extension sync, tool bootstrap | +| **LSP** | Language Server Protocol client and multiplexer | +| **Mac Tools** | macOS-native utilities (Swift CLI) | +| **MCP Server/Client** | Model Context Protocol server and client | +| **Memory Extension** | In-session memory pipeline and storage | +| **Migration** | Data and config migration tools | +| **Modes** | Interactive TUI, Print, RPC, and Web modes | +| **Model System** | Model discovery, resolution, routing, registry | +| **Native / Rust Tools** | N-API Rust engine modules | +| **Node.js Bindings** | TypeScript wrappers around Rust N-API modules | +| **Onboarding** | First-run wizard and setup flows | +| **Permissions** | Permission management for tools and trust | +| **Remote Questions** | Remote prompting via Slack, Discord, Telegram | +| **Search the Web** | Brave/Jina/Tavily-based web search extension | +| **Session Management** | Session file I/O, branches, fork trees | +| **Skills** | Skill tool registration, health, telemetry | +| **Slash Commands** | Command boilerplate generators extension | +| **State Machine** | State, history, persistence, reactive graph | +| **Studio App** | Electron desktop app (renderer, main, preload) | +| **Subagent** | Parallel/serial subagent delegation | +| **Syntax Highlighting** | Syntect-backed ANSI code coloring | +| **Text Processing** | Diff, truncation, HTML→MD, ANSI, JSON parse | +| **Tool System** | Tool implementations (bash, edit, read, write, grep…) | +| **TTSR** | Time-Traveling Stream Rules regex guardrails | +| **TUI Components** | Terminal UI component library (pi-tui) | +| **Universal Config** | Multi-tool configuration file discovery | +| **Voice** | Voice input extension (Swift/Python) | +| **VS Code Extension** | VS Code sidebar, chat participant, RPC client | +| **Web Mode** | Web server service layer and RPC bridge | +| **Web UI** | Next.js frontend components, pages, hooks | +| **Worktree** | Git worktree lifecycle, sync, name generation | + +--- + +## src/ — Core Application Files + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| src/app-paths.ts | Config | App directory paths (GSD_HOME, sessions, web PID, prefs) | +| src/app-paths.js | Config | Compiled JS version | +| src/bundled-extension-paths.ts | Extension Registry | Serializes/parses bundled extension directory paths | +| src/bundled-resource-path.ts | Loader/Bootstrap, Extension Registry | Resolves bundled raw resource files from package root | +| src/cli.ts | CLI | Main CLI entry point — arg parsing, mode detection, plugin init | +| src/cli-web-branch.ts | CLI, Web Mode | Web CLI branch; session dir resolution, legacy migration | +| src/extension-discovery.ts | Extension Registry | Discovers extension entry points from FS and package.json | +| src/extension-registry.ts | Extension Registry | Extension manifests, registry persistence, enable/disable | +| src/headless-answers.ts | Headless Mode | Pre-supply answers to extension UI requests in headless | +| src/headless-context.ts | Headless Mode | Context loading from stdin/files; project bootstrapping | +| src/headless-events.ts | Headless Mode | Event classification, terminal detection, idle timeouts | +| src/headless-query.ts | Headless Mode, CLI | Read-only snapshot query (state, dispatch preview, costs) | +| src/headless-ui.ts | Headless Mode | Extension UI auto-response, progress formatting | +| src/headless.ts | Headless Mode | Orchestrator for /gsd subcommands without TUI via RPC | +| src/help-text.ts | CLI | Generates help text for all subcommands | +| src/loader.ts | Loader/Bootstrap | Fast-path startup, extension discovery/validation, env setup | +| src/logo.ts | CLI | ASCII logo rendering for welcome screen and loader | +| src/mcp-server.ts | MCP Server/Client | Native MCP server over stdin/stdout for external AI clients | +| src/models-resolver.ts | Config, Auth/OAuth | Resolves models.json with fallback from Pi to GSD | +| src/onboarding.ts | Onboarding | First-run wizard — LLM auth, OAuth, API keys, tool setup | +| src/pi-migration.ts | Config, Auth/OAuth | Migrates provider credentials from Pi auth.json to GSD | +| src/project-sessions.ts | State Machine, CLI | Session-per-project directory paths from project CWD | +| src/remote-questions-config.ts | Config, Onboarding | Saves remote questions (Discord, Slack, Telegram) config | +| src/resource-loader.ts | Loader/Bootstrap, Extension Registry | Initializes, syncs, validates bundled resources | +| src/startup-timings.ts | CLI, Build System | Optional startup timing instrumentation | +| src/tool-bootstrap.ts | Loader/Bootstrap | Manages fd/rg availability, falls back to built-in | +| src/update-check.ts | CLI | Checks npm registry for new versions (cached) | +| src/update-cmd.ts | CLI | Executes npm install to update gsd-pi package | +| src/web-mode.ts | Web Mode | Launches/manages web server process (PID tracking, browser) | +| src/welcome-screen.ts | CLI | Welcome panel — logo, version, model info | +| src/wizard.ts | Onboarding, Config | Loads env keys from auth.json → hydrates process.env | +| src/worktree-cli.ts | Worktree, CLI | Worktree lifecycle: create, list, merge, clean, remove | +| src/worktree-name-gen.ts | Worktree | Generates random worktree names (adjective-verbing-noun) | + +### src/web/ — Web Service Layer + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| src/web/auto-dashboard-service.ts | Web Mode, Auto Engine | Loads auto-mode dashboard state (active, paused, costs) | +| src/web/bridge-service.ts | Web Mode, State Machine | Central hub spawning RPC sessions, managing session state | +| src/web/captures-service.ts | Web Mode | Loads knowledge capture entries via child process bridge | +| src/web/cleanup-service.ts | Web Mode | Collects GSD branches and snapshot refs for cleanup | +| src/web/cli-entry.ts | Web Mode, CLI | Builds/resolves GSD CLI entry points for RPC/interactive | +| src/web/doctor-service.ts | Web Mode, Doctor/Diagnostics | Runs diagnostics, returns fixer operations | +| src/web/export-service.ts | Web Mode | Generates exported project reports (markdown/JSON) | +| src/web/forensics-service.ts | Web Mode, Doctor/Diagnostics | Loads forensic report data (traces, metrics, issues) | +| src/web/git-summary-service.ts | Web Mode | Provides git branch, commit history, diff summary | +| src/web/history-service.ts | Web Mode | Loads metrics ledger, aggregates history views | +| src/web/hooks-service.ts | Web Mode | Manages git hook registration and shell integration | +| src/web/inspect-service.ts | Web Mode | Detailed inspection of project state and traces | +| src/web/knowledge-service.ts | Web Mode | Reads and parses KNOWLEDGE.md | +| src/web/onboarding-service.ts | Web Mode, Onboarding, Auth/OAuth | Manages onboarding state, auth refresh, lock reasons | +| src/web/project-discovery-service.ts | Web Mode | Discovers and catalogs projects in filesystem | +| src/web/recovery-diagnostics-service.ts | Web Mode | Recovery suggestions for error states/blockers | +| src/web/settings-service.ts | Web Mode, Config | Loads preferences, routing config, budget, totals | +| src/web/skill-health-service.ts | Web Mode, Doctor/Diagnostics | Loads skill health report with capability assessments | +| src/web/undo-service.ts | Web Mode | Manages undo/snapshot and restoration | +| src/web/update-service.ts | Web Mode | Checks for and executes application updates | +| src/web/visualizer-service.ts | Web Mode | Generates visual representations of project state | +| src/web/web-auth-storage.ts | Web Mode, Auth/OAuth | OAuth and API key credential storage for web mode | + +--- + +## packages/pi-agent-core/src/ — Agent Core + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| agent-loop.ts | Agent Core, State Machine | Core agent execution loop — tool calls and LLM interactions | +| agent.ts | Agent Core | Main Agent class wrapping loop with state management | +| proxy.ts | Agent Core | Proxy wrapper for agent functionality | +| types.ts | Agent Core | Type definitions for agent config, context, events | +| index.ts | Agent Core | Package exports | + +--- + +## packages/pi-ai/src/ — AI Providers + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| index.ts | AI Providers | Main export hub for providers and streaming | +| api-registry.ts | AI Providers | Registry for managing multiple AI provider implementations | +| models.ts | AI Providers | Model definitions and metadata | +| models.generated.ts | AI Providers | Auto-generated model list from provider registries | +| stream.ts | AI Providers | Main streaming interface dispatching to registered providers | +| types.ts | AI Providers | Core types for models, APIs, streaming options | +| env-api-keys.ts | AI Providers, Auth/OAuth | Environment variable API key resolution | +| web-runtime-env-api-keys.ts | AI Providers, Auth/OAuth | Web runtime API key handling | +| web-runtime-oauth.ts | AI Providers, Auth/OAuth | Web runtime OAuth token management | +| providers/register-builtins.ts | AI Providers | Registration of built-in provider implementations | +| providers/anthropic.ts | AI Providers | Anthropic API provider | +| providers/anthropic-shared.ts | AI Providers | Shared utilities for Anthropic provider variants | +| providers/anthropic-vertex.ts | AI Providers | Google Vertex AI Anthropic models | +| providers/amazon-bedrock.ts | AI Providers | AWS Bedrock LLM provider | +| providers/bedrock-provider.ts | AI Providers | Bedrock-specific streaming logic | +| providers/google.ts | AI Providers | Google Generative AI provider | +| providers/google-gemini-cli.ts | AI Providers | Google Gemini CLI authentication provider | +| providers/google-shared.ts | AI Providers | Shared Google provider utilities | +| providers/google-vertex.ts | AI Providers | Google Vertex AI provider | +| providers/mistral.ts | AI Providers | Mistral AI provider | +| providers/openai-completions.ts | AI Providers | OpenAI legacy completions API | +| providers/openai-responses.ts | AI Providers | OpenAI responses (chat) API | +| providers/openai-responses-shared.ts | AI Providers | Shared OpenAI responses utilities | +| providers/openai-shared.ts | AI Providers | Shared OpenAI utilities | +| providers/openai-codex-responses.ts | AI Providers | OpenAI Codex-specific response handling | +| providers/azure-openai-responses.ts | AI Providers | Azure OpenAI responses provider | +| providers/github-copilot-headers.ts | AI Providers | GitHub Copilot custom header construction | +| providers/simple-options.ts | AI Providers | Common options builder for simple streaming | +| providers/transform-messages.ts | AI Providers | Message transformation for provider compatibility | +| utils/oauth/index.ts | Auth/OAuth | OAuth utilities export hub | +| utils/oauth/types.ts | Auth/OAuth | OAuth credential and prompt types | +| utils/oauth/pkce.ts | Auth/OAuth | PKCE flow implementation | +| utils/oauth/github-copilot.ts | Auth/OAuth | GitHub Copilot OAuth flow | +| utils/oauth/google-oauth-utils.ts | Auth/OAuth | Shared Google OAuth utilities | +| utils/oauth/google-gemini-cli.ts | Auth/OAuth | Google Gemini CLI OAuth flow | +| utils/oauth/google-antigravity.ts | Auth/OAuth | Google Antigravity OAuth implementation | +| utils/oauth/openai-codex.ts | Auth/OAuth | OpenAI Codex OAuth flow | +| utils/oauth/anthropic.ts | Auth/OAuth | Anthropic OAuth flow | +| utils/event-stream.ts | AI Providers | Event stream parsing and handling | +| utils/hash.ts | AI Providers | Hashing utilities | +| utils/json-parse.ts | AI Providers | Resilient JSON parsing with recovery | +| utils/overflow.ts | AI Providers | Token/context overflow detection | +| utils/sanitize-unicode.ts | AI Providers | Unicode sanitization for API compatibility | +| utils/validation.ts | AI Providers | Request/response validation schemas | +| utils/typebox-helpers.ts | AI Providers | TypeBox schema helpers | + +--- + +## packages/pi-tui/src/ — TUI Components + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| index.ts | TUI Components | Main TUI export hub | +| tui.ts | TUI Components | Core TUI renderer and component system | +| terminal.ts | TUI Components | Low-level terminal I/O and rendering | +| keys.ts | TUI Components | Keyboard key parsing and matching | +| keybindings.ts | TUI Components | Keybinding configuration and management | +| stdin-buffer.ts | TUI Components | Buffered stdin for batch key processing | +| editor-component.ts | TUI Components | Interface for custom editor implementations | +| autocomplete.ts | TUI Components | Autocomplete suggestion provider system | +| fuzzy.ts | TUI Components | Fuzzy matching algorithm | +| terminal-image.ts | TUI Components | Terminal image protocol (Kitty, iTerm2) | +| kill-ring.ts | TUI Components | Emacs-style kill ring buffer | +| undo-stack.ts | TUI Components | Undo/redo stack for editor operations | +| overlay-layout.ts | TUI Components | Overlay/modal dialog layout system | +| utils.ts | TUI Components | Text width calculation, ANSI utilities | +| components/box.ts | TUI Components | Box drawing with borders and styling | +| components/text.ts | TUI Components | Simple text display component | +| components/truncated-text.ts | TUI Components | Text with automatic truncation | +| components/spacer.ts | TUI Components | Vertical/horizontal spacing | +| components/input.ts | TUI Components | Single-line text input with history | +| components/loader.ts | TUI Components | Animated loading spinner | +| components/cancellable-loader.ts | TUI Components | Loading spinner with cancel | +| components/image.ts | TUI Components | Image display with theme support | +| components/select-list.ts | TUI Components | List selection UI with keyboard nav | +| components/settings-list.ts | TUI Components | Settings/preferences list display | +| components/editor.ts | TUI Components | Full multi-line editor with syntax awareness | +| components/markdown.ts | TUI Components | Markdown rendering to terminal | + +--- + +## packages/pi-coding-agent/src/ — Coding Agent + +### CLI + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| cli.ts | CLI | Main CLI entry point and argument routing | +| main.ts | CLI | CLI main entry with mode routing | +| cli/args.ts | CLI | CLI argument definition and parsing | +| cli/config-selector.ts | CLI | Interactive configuration selection | +| cli/file-processor.ts | CLI | File input processing for agent context | +| cli/list-models.ts | CLI, Model System | Model listing and discovery UI | +| cli/session-picker.ts | CLI | Session selection interface | + +### Core — Session & State + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/agent-session.ts | Agent Core, State Machine | Core session abstraction, agent lifecycle, persistence | +| core/session-manager.ts | Session Management | Session file I/O, branch/fork tree management | +| core/event-bus.ts | Agent Core, Event System | Event publication and subscription | +| core/messages.ts | State Machine | Message type definitions and constructors | +| core/settings-manager.ts | Session Management, Config | Session-level settings persistence | + +### Core — Tool System + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/tools/index.ts | Tool System | Tool registry and factory exports | +| core/tools/bash.ts | Tool System | Bash/shell command execution tool | +| core/tools/bash-interceptor.ts | Tool System | Bash command interception and filtering | +| core/tools/edit.ts | Tool System | File editing tool with line ranges | +| core/tools/edit-diff.ts | Tool System | Edit tool with diff-based operations | +| core/tools/read.ts | Tool System | File reading tool | +| core/tools/write.ts | Tool System | File writing tool | +| core/tools/find.ts | Tool System, File Search | File discovery tool | +| core/tools/grep.ts | Tool System, File Search | Pattern search tool | +| core/tools/ls.ts | Tool System | Directory listing tool | +| core/tools/truncate.ts | Tool System, Text Processing | Output truncation utility | +| core/tools/hashline.ts | Tool System | Hash-based line identification | +| core/tools/hashline-read.ts | Tool System | File reading with hash-based line ranges | +| core/tools/hashline-edit.ts | Tool System | File editing with hash-based line identification | +| core/tools/path-utils.ts | Tool System | Path normalization and validation | +| core/bash-executor.ts | Tool System | High-level bash execution with event handling | +| core/exec.ts | Tool System | Utility functions for command execution | + +### Core — Model Management + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/model-registry.ts | Model System | Model metadata and capability registry | +| core/model-discovery.ts | Model System | Model discovery from external sources | +| core/model-resolver.ts | Model System | Model selection and resolution logic | +| core/models-json-writer.ts | Model System | Model metadata serialization | + +### Core — AI & Context + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/prompt-templates.ts | Agent Core | Template system for prompt construction | +| core/system-prompt.ts | Agent Core | System prompt building and management | +| core/retry-handler.ts | AI Providers | Retry logic with exponential backoff | +| core/fallback-resolver.ts | Model System | Model fallback resolution on API failures | +| core/slash-commands.ts | Commands | Built-in slash command definitions and handlers | + +### Core — Extensions & Skills + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/extensions/index.ts | Extensions | Extension system exports | +| core/extensions/types.ts | Extensions | Extension event and context types | +| core/extensions/loader.ts | Extensions | Extension discovery and loading | +| core/extensions/runner.ts | Extensions, Event System | Extension event dispatch and execution | +| core/extensions/wrapper.ts | Extensions, Tool System | Tool wrapping for extension monitoring | +| core/extensions/project-trust.ts | Extensions, Permissions | Project trust management for local extensions | +| core/skills.ts | Skills, Tool System | Skill tool registration and management | + +### Core — Compaction + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/compaction-orchestrator.ts | Compaction | Orchestrates session compaction decisions | +| core/compaction/compaction.ts | Compaction | Context token reduction via summarization | +| core/compaction/branch-summarization.ts | Compaction | Branch history summarization for context limits | +| core/compaction/utils.ts | Compaction | Compaction utilities | + +### Core — Configuration & Auth + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| config.ts | Config | Directory paths and version management | +| core/sdk.ts | Agent Core | Main SDK factory for creating agent sessions | +| core/resolve-config-value.ts | Config | Config value resolution from environment/files | +| core/resource-loader.ts | Config, Loader/Bootstrap | Extensible resource loading (tools, extensions, themes) | +| core/defaults.ts | Config | Default configuration values | +| core/constants.ts | Config | Global constants | +| core/auth-storage.ts | Auth/OAuth, Permissions | OAuth token storage and management | +| migrations.ts | Config, Migration | Configuration migration and deprecation handling | + +### Core — Artifacts & Export + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/artifact-manager.ts | Agent Core | Artifact file management and metadata | +| core/blob-store.ts | Agent Core | Binary data storage for images and attachments | +| core/export-html/index.ts | Web Mode | Session export to HTML | +| core/export-html/ansi-to-html.ts | Web Mode | ANSI code to HTML conversion | +| core/export-html/tool-renderer.ts | Web Mode | HTML rendering for tool calls/results | + +### Core — LSP + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/lsp/index.ts | LSP | LSP integration exports | +| core/lsp/client.ts | LSP | LSP client implementation | +| core/lsp/lspmux.ts | LSP | LSP server multiplexing | +| core/lsp/config.ts | LSP | LSP server configuration | +| core/lsp/edits.ts | LSP | LSP-based code editing operations | +| core/lsp/helpers.ts | LSP | LSP utility functions | +| core/lsp/types.ts | LSP | LSP type definitions | +| core/lsp/utils.ts | LSP | LSP utilities | + +### Core — Utilities + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| core/fs-utils.ts | Tool System | File system utilities (atomic writes, temp files) | +| core/lock-utils.ts | Tool System | File locking for concurrent access | +| core/timings.ts | Build System | Performance timing measurement | +| core/diagnostics.ts | Doctor/Diagnostics | Diagnostic information collection | +| core/discovery-cache.ts | Model System | Model discovery result caching | +| core/keybindings.ts | TUI Components | Keybinding definitions | +| core/footer-data-provider.ts | TUI Components | Footer information provider | +| core/index.ts | Agent Core | Core module exports | +| index.ts | Agent Core | Package exports | +| utils/clipboard.ts | Tool System | Clipboard read/write | +| utils/clipboard-native.ts | Tool System | Native clipboard implementation | +| utils/clipboard-image.ts | Tool System | Clipboard image support | +| utils/error.ts | Agent Core | Error message extraction/formatting | +| utils/frontmatter.ts | Config | YAML frontmatter parsing | +| utils/git.ts | Tool System | Git information and utilities | +| utils/image-convert.ts | Image Processing | Image format conversion | +| utils/image-resize.ts | Image Processing | Image resizing and optimization | +| utils/mime.ts | Tool System | MIME type detection | +| utils/path-display.ts | TUI Components | Path formatting for display | +| utils/photon.ts | Agent Core | Photon scripting runtime support | +| utils/shell.ts | Tool System | Shell detection and execution | +| utils/changelog.ts | CLI | Changelog parsing | +| utils/sleep.ts | Agent Core | Async sleep/delay utility | +| utils/tools-manager.ts | Tool System | Tool discovery and management | +| package-manager.ts | Build System | npm/yarn/pnpm/bun abstraction | + +### Modes + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| modes/index.ts | Modes | Mode system exports | +| modes/print-mode.ts | Modes | Non-interactive print mode | +| modes/rpc/rpc-mode.ts | Modes, MCP Server/Client | RPC server mode for remote access | +| modes/rpc/rpc-client.ts | Modes, MCP Server/Client | RPC client for remote agent interaction | +| modes/rpc/rpc-types.ts | Modes, MCP Server/Client | RPC protocol type definitions | +| modes/rpc/jsonl.ts | Modes | JSONL serialization for RPC | +| modes/rpc/remote-terminal.ts | Modes | Remote terminal output handling | +| modes/shared/command-context-actions.ts | Modes, Commands | Shared command context utilities | +| modes/interactive/interactive-mode.ts | Modes, TUI Components | Main interactive TUI mode orchestration | +| modes/interactive/interactive-mode-state.ts | Modes, TUI Components, State Machine | Interactive mode state management | +| modes/interactive/slash-command-handlers.ts | Modes, Commands | Interactive mode slash command handlers | +| modes/interactive/theme/theme.ts | TUI Components | Theme system and hot reloading | +| modes/interactive/theme/themes.ts | TUI Components | Built-in theme definitions | +| modes/interactive/utils/shorten-path.ts | TUI Components | Path shortening for display | +| modes/interactive/controllers/chat-controller.ts | Modes, TUI Components | Chat input and message submission | +| modes/interactive/controllers/input-controller.ts | Modes, TUI Components | Input handling and routing | +| modes/interactive/controllers/model-controller.ts | Modes, TUI Components, Model System | Model/provider/thinking configuration | +| modes/interactive/controllers/extension-ui-controller.ts | Modes, TUI Components, Extensions | Extension UI event handling | + +### Modes — Interactive Components + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| components/index.ts | TUI Components | Interactive mode component exports | +| components/armin.ts | TUI Components | Assistant message rendering | +| components/assistant-message.ts | TUI Components | Assistant message display | +| components/user-message.ts | TUI Components | User message display | +| components/user-message-selector.ts | TUI Components | User message editing selector | +| components/bash-execution.ts | TUI Components, Tool System | Bash execution result display | +| components/tool-execution.ts | TUI Components, Tool System | Tool call and result display | +| components/custom-message.ts | TUI Components | Custom message type display | +| components/custom-editor.ts | TUI Components | Custom editor integration | +| components/skill-invocation-message.ts | TUI Components, Skills | Skill invocation display | +| components/branch-summary-message.ts | TUI Components, Compaction | Branch summary display | +| components/compaction-summary-message.ts | TUI Components, Compaction | Compaction summary display | +| components/diff.ts | TUI Components, Text Processing | Diff display component | +| components/tree-render-utils.ts | TUI Components, Session Management | Session tree rendering utilities | +| components/tree-selector.ts | TUI Components, Session Management | Session tree navigation UI | +| components/session-selector.ts | TUI Components, Session Management | Session selection UI | +| components/session-selector-search.ts | TUI Components, Session Management | Session search UI | +| components/model-selector.ts | TUI Components, Model System | Model selection UI | +| components/scoped-models-selector.ts | TUI Components, Model System | Scoped model selection | +| components/thinking-selector.ts | TUI Components, Model System | Thinking level selection | +| components/provider-manager.ts | TUI Components, AI Providers | Provider configuration UI | +| components/oauth-selector.ts | TUI Components, Auth/OAuth | OAuth provider selection/login | +| components/login-dialog.ts | TUI Components, Auth/OAuth | OAuth login dialog | +| components/theme-selector.ts | TUI Components | Theme selection UI | +| components/config-selector.ts | TUI Components, Config | Configuration selection UI | +| components/extension-selector.ts | TUI Components, Extensions | Extension selection UI | +| components/extension-editor.ts | TUI Components, Extensions | Extension code editor | +| components/extension-input.ts | TUI Components, Extensions | Extension input handling | +| components/settings-selector.ts | TUI Components, Config | Settings/preferences UI | +| components/show-images-selector.ts | TUI Components, Config | Image display toggle | +| components/bordered-loader.ts | TUI Components | Loading spinner with border | +| components/countdown-timer.ts | TUI Components | Countdown timer display | +| components/dynamic-border.ts | TUI Components | Dynamic border drawing | +| components/keybinding-hints.ts | TUI Components | Keybinding help display | +| components/footer.ts | TUI Components | Footer information display | +| components/daxnuts.ts | TUI Components | Special rendering effect | +| components/visual-truncate.ts | TUI Components | Visual text truncation | + +### Resources — Memory Extension + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| resources/extensions/memory/index.ts | Memory Extension | Memory extension index and setup | +| resources/extensions/memory/pipeline.ts | Memory Extension | Memory processing pipeline | +| resources/extensions/memory/storage.ts | Memory Extension | Memory persistence storage | + +--- + +## src/resources/extensions/ — Extension Subsystems + +### GSD Extension (Core Workflow Engine) + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| gsd/index.ts | GSD Workflow | Main GSD extension bootstrap and registration | +| gsd/auto.ts | Auto Engine | Automatic workflow execution and loop management | +| gsd/auto-dashboard.ts | Auto Engine, Web Mode | Real-time dashboard for auto-run progress | +| gsd/auto-worktree.ts | Auto Engine, Worktree | Automatic worktree creation and branch management | +| gsd/auto-recovery.ts | Auto Engine | Recovery for crashed/stalled workflows | +| gsd/auto-start.ts | Auto Engine | Initialization sequence for automatic execution | +| gsd/auto-worktree-sync.ts | Auto Engine, Worktree | State sync between worktrees and main | +| gsd/auto-model-selection.ts | Auto Engine, Model System | Intelligent LLM model routing | +| gsd/auto-direct-dispatch.ts | Auto Engine | Direct command dispatching without planning | +| gsd/auto-dispatch.ts | Auto Engine | Task queueing and priority-based dispatch | +| gsd/auto-timeout-recovery.ts | Auto Engine | Timeout handling and recovery | +| gsd/auto-post-unit.ts | Auto Engine | Post-unit milestone completion processing | +| gsd/auto-unit-closeout.ts | Auto Engine | Unit finalization and archiving | +| gsd/auto-verification.ts | Auto Engine | Post-execution verification | +| gsd/auto-timers.ts | Auto Engine | Timeout and deadline management | +| gsd/auto-loop.ts | Auto Engine, State Machine | Execution loop state and cycle management | +| gsd/auto-supervisor.ts | Auto Engine | Supervision and oversight of autonomous runs | +| gsd/auto-budget.ts | Auto Engine | Token/cost budgeting and tracking | +| gsd/auto-observability.ts | Auto Engine | Observability hooks and telemetry | +| gsd/auto-tool-tracking.ts | Auto Engine | Tool usage instrumentation | +| gsd/doctor.ts | Doctor/Diagnostics | Health check and system diagnostics | +| gsd/doctor-checks.ts | Doctor/Diagnostics | Individual diagnostic checks | +| gsd/doctor-providers.ts | Doctor/Diagnostics | Diagnostic data source providers | +| gsd/doctor-format.ts | Doctor/Diagnostics | Diagnostic output formatting | +| gsd/state.ts | State Machine | Milestone and workflow state management | +| gsd/history.ts | State Machine | State history and versioning | +| gsd/json-persistence.ts | State Machine | JSON-based persistence layer | +| gsd/memory-store.ts | State Machine | In-memory state storage | +| gsd/reactive-graph.ts | State Machine | Reactive dependency graph for state | +| gsd/routing-history.ts | State Machine | History of routing decisions | +| gsd/cache.ts | State Machine | Caching layer for performance | +| gsd/model-router.ts | Model System | LLM model selection and routing logic | +| gsd/worktree.ts | Worktree | Worktree creation and management | +| gsd/worktree-manager.ts | Worktree | Higher-level worktree orchestration | +| gsd/worktree-resolver.ts | Worktree | Worktree path and reference resolution | +| gsd/unit-runtime.ts | Auto Engine | Unit-level execution runtime | +| gsd/activity-log.ts | GSD Workflow | Activity tracking and logging | +| gsd/debug-logger.ts | GSD Workflow | Debug output and verbose logging | +| gsd/commands.ts | Commands | Main command dispatcher | +| gsd/commands-handlers.ts | Commands | Command-specific handlers | +| gsd/commands-bootstrap.ts | Commands | Bootstrap and initialization commands | +| gsd/commands-config.ts | Commands, Config | Configuration management commands | +| gsd/commands-extensions.ts | Commands, Extensions | Extension discovery and management | +| gsd/commands-inspect.ts | Commands, Doctor/Diagnostics | Database and state inspection tools | +| gsd/commands-logs.ts | Commands | Log viewing and filtering | +| gsd/commands-workflow-templates.ts | Commands, GSD Workflow | Workflow template management | +| gsd/commands-cmux.ts | Commands, CMux | Tmux/cmux integration commands | +| gsd/exit-command.ts | Commands | Exit and cleanup commands | +| gsd/undo.ts | Commands | Undo and rollback functionality | +| gsd/kill.ts | Commands | Process termination and cleanup | +| gsd/worktree-command.ts | Commands, Worktree | Worktree subcommands | +| gsd/namespaced-resolver.ts | GSD Workflow | Namespace and scoped resource resolution | +| gsd/error-utils.ts | GSD Workflow | Error handling and formatting | +| gsd/errors.ts | GSD Workflow | Error type definitions | +| gsd/diff-context.ts | GSD Workflow | Diff-based context extraction | +| gsd/memory-extractor.ts | GSD Workflow | Memory and context extraction from state | +| gsd/structured-data-formatter.ts | GSD Workflow | Structured output formatting | +| gsd/export-html.ts | GSD Workflow | HTML export of milestone reports | +| gsd/reports.ts | GSD Workflow | Report generation and summaries | +| gsd/notifications.ts | GSD Workflow | User notification and messaging | +| gsd/triage-ui.ts | GSD Workflow | Triage interface for issue categorization | +| gsd/guided-flow.ts | GSD Workflow | User-guided workflow orchestration | +| gsd/env-utils.ts | GSD Workflow | Environment variable utilities | +| gsd/git-constants.ts | GSD Workflow | Git-related constants and paths | +| gsd/milestone-id-utils.ts | GSD Workflow | Milestone ID generation and parsing | +| gsd/resource-version.ts | GSD Workflow | Resource versioning helpers | +| gsd/atomic-write.ts | GSD Workflow | Atomic file write operations | +| gsd/captures.ts | GSD Workflow | Artifact capture and storage | +| gsd/changelog.ts | GSD Workflow | Changelog generation | +| gsd/claude-import.ts | GSD Workflow | Claude API/resource importing | +| gsd/collision-diagnostics.ts | Doctor/Diagnostics | Collision detection and diagnostics | +| gsd/prompt-loader.ts | GSD Workflow | Prompt template loading | +| gsd/file-watcher.ts | GSD Workflow | File system change monitoring | +| gsd/parallel-eligibility.ts | GSD Workflow | Parallel execution eligibility checks | +| gsd/plugin-importer.ts | GSD Workflow, Extensions | Custom plugin/extension importing | +| gsd/verification-gate.ts | GSD Workflow | Pre-execution verification checks | +| gsd/preference-models.ts | Config, Model System | Model preference configuration | +| gsd/preferences-skills.ts | Config, Skills | Skill preference configuration | +| gsd/post-unit-hooks.ts | GSD Workflow | Post-unit execution hooks | +| gsd/skill-telemetry.ts | Skills | Skill usage and performance telemetry | +| gsd/bootstrap/* | GSD Workflow, Loader/Bootstrap | Extension initialization and hook registration | +| gsd/auto/* | Auto Engine | Auto-execution engine components | +| gsd/commands/* | Commands | Command routing and handling | +| gsd/templates/* | GSD Workflow | Output templates and formatters | +| gsd/prompts/* | GSD Workflow | System prompts and instructions | +| gsd/workflow-templates/* | GSD Workflow | Workflow starter templates and registry | +| gsd/skills/* | Skills | Integrated skill configurations | +| gsd/migrate/* | Migration | Data migration and upgrade tools | + +### Other Extensions + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| async-jobs/index.ts | Async Jobs | Background bash command execution extension | +| async-jobs/job-manager.ts | Async Jobs | Background job lifecycle management | +| async-jobs/async-bash-tool.ts | Async Jobs, Tool System | Tool for spawning background bash processes | +| async-jobs/await-tool.ts | Async Jobs, Tool System | Tool for waiting on job completion | +| async-jobs/cancel-job-tool.ts | Async Jobs, Tool System | Tool for cancelling background jobs | +| bg-shell/index.ts | Bg Shell | Interactive background process management extension | +| bg-shell/bg-shell-tool.ts | Bg Shell, Tool System | Tool for spawning background processes | +| bg-shell/bg-shell-command.ts | Bg Shell, Commands | Command handler for bg subcommands | +| bg-shell/bg-shell-lifecycle.ts | Bg Shell | Process lifecycle and state management | +| bg-shell/process-manager.ts | Bg Shell | Core process management implementation | +| bg-shell/readiness-detector.ts | Bg Shell | Startup readiness detection | +| bg-shell/interaction.ts | Bg Shell | Interactive process communication | +| bg-shell/output-formatter.ts | Bg Shell | Process output formatting | +| bg-shell/overlay.ts | Bg Shell, TUI Components | Terminal overlay for process monitoring | +| browser-tools/index.ts | Browser Tools | Playwright-based browser automation extension | +| browser-tools/core.ts | Browser Tools | Core Playwright instance management | +| browser-tools/lifecycle.ts | Browser Tools | Browser session lifecycle | +| browser-tools/capture.ts | Browser Tools | Screenshot and media capture | +| browser-tools/settle.ts | Browser Tools | Page settlement and readiness detection | +| browser-tools/refs.ts | Browser Tools | Reference-based element selection | +| browser-tools/state.ts | Browser Tools, State Machine | Browser state management | +| browser-tools/tools/navigation.ts | Browser Tools, Tool System | Navigation and page loading tool | +| browser-tools/tools/interaction.ts | Browser Tools, Tool System | Element interaction tool (click, type) | +| browser-tools/tools/screenshot.ts | Browser Tools, Tool System | Screenshot and visual capture tool | +| browser-tools/tools/inspection.ts | Browser Tools, Tool System | Page inspection tool | +| browser-tools/tools/session.ts | Browser Tools, Tool System | Session management and cookies tool | +| browser-tools/tools/pages.ts | Browser Tools, Tool System | Multi-page management tool | +| browser-tools/tools/forms.ts | Browser Tools, Tool System | Form filling and submission tool | +| browser-tools/tools/wait.ts | Browser Tools, Tool System | Wait conditions and polling tool | +| browser-tools/tools/assertions.ts | Browser Tools, Tool System | Visual and content assertions tool | +| browser-tools/tools/verify.ts | Browser Tools, Tool System | Verification checks tool | +| browser-tools/tools/extract.ts | Browser Tools, Tool System | Data extraction tool | +| browser-tools/tools/pdf.ts | Browser Tools, Tool System | PDF export/generation tool | +| browser-tools/tools/state-persistence.ts | Browser Tools, Tool System | State save/restore tool | +| browser-tools/tools/network-mock.ts | Browser Tools, Tool System | Network mocking/interception tool | +| browser-tools/tools/device.ts | Browser Tools, Tool System | Device emulation tool | +| browser-tools/tools/visual-diff.ts | Browser Tools, Tool System | Visual regression testing tool | +| browser-tools/tools/zoom.ts | Browser Tools, Tool System | Zoom and viewport manipulation tool | +| browser-tools/tools/codegen.ts | Browser Tools, Tool System | Test code generation tool | +| browser-tools/tools/action-cache.ts | Browser Tools | Action caching and replay | +| context7/index.ts | Context7, Tool System | Library documentation fetching extension | +| google-search/index.ts | Google Search, Tool System | Web search via Google API | +| search-the-web/index.ts | Search the Web | Brave/Jina/Tavily-based web search extension | +| search-the-web/provider.ts | Search the Web | Search provider abstraction | +| search-the-web/native-search.ts | Search the Web | Native Brave search implementation | +| search-the-web/tavily.ts | Search the Web | Tavily search provider | +| search-the-web/tool-search.ts | Search the Web, Tool System | Search tool implementation | +| search-the-web/tool-fetch-page.ts | Search the Web, Tool System | Page fetching tool | +| search-the-web/cache.ts | Search the Web | Search result caching | +| remote-questions/index.ts | Remote Questions | Remote question routing extension | +| remote-questions/manager.ts | Remote Questions | Question lifecycle management | +| remote-questions/slack-adapter.ts | Remote Questions | Slack messaging adapter | +| remote-questions/discord-adapter.ts | Remote Questions | Discord messaging adapter | +| remote-questions/telegram-adapter.ts | Remote Questions | Telegram messaging adapter | +| mcp-client/index.ts | MCP Server/Client | Model Context Protocol client integration | +| subagent/index.ts | Subagent, Agent Core | Parallel/serial subagent delegation extension | +| subagent/agents.ts | Subagent, Agent Core | Agent registry and discovery | +| subagent/isolation.ts | Subagent | Execution isolation and sandboxing | +| subagent/worker-registry.ts | Subagent | Worker process management | +| slash-commands/index.ts | Slash Commands, Commands | Command boilerplate generators extension | +| slash-commands/create-slash-command.ts | Slash Commands | Generator for new slash command scaffolding | +| slash-commands/create-extension.ts | Slash Commands, Extensions | Generator for new extension scaffolding | +| universal-config/index.ts | Universal Config | Multi-tool configuration file discovery | +| universal-config/discovery.ts | Universal Config | Configuration file discovery | +| universal-config/scanners.ts | Universal Config | Tool-specific config scanners | +| ttsr/index.ts | TTSR | TTSR regex engine — streaming output guardrails | +| ttsr/ttsr-manager.ts | TTSR | Streaming rule manager | +| ttsr/rule-loader.ts | TTSR | Rule loading and parsing | +| voice/index.ts | Voice | Voice input mode extension | +| voice/speech-recognizer.swift | Voice | macOS Swift speech recognizer | +| voice/speech-recognizer.py | Voice | Linux/Windows Python speech recognizer | +| cmux/index.ts | CMux | Tmux/multiplexer session management | +| mac-tools/index.ts | Mac Tools | macOS-specific utilities extension | +| mac-tools/swift-cli/Sources/main.swift | Mac Tools | macOS native tools Swift implementation | +| aws-auth/index.ts | Auth/OAuth | AWS authentication and credential handling | +| shared/ui.ts | TUI Components | Generic UI components and utilities | +| shared/tui.ts | TUI Components | Terminal UI helpers | +| shared/interview-ui.ts | TUI Components | Interview-style questionnaire UI | +| shared/confirm-ui.ts | TUI Components | Confirmation dialog UI | +| shared/terminal.ts | TUI Components | Terminal operations and formatting | +| shared/format-utils.ts | GSD Workflow | String formatting utilities | +| shared/sanitize.ts | GSD Workflow | Input sanitization | +| shared/frontmatter.ts | Config | YAML frontmatter parsing | + +### src/resources/agents/ + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| javascript-pro.md | Subagent | JavaScript specialist agent definition | +| typescript-pro.md | Subagent | TypeScript specialist agent definition | +| worker.md | Subagent | Generic worker agent definition | +| researcher.md | Subagent | Research and exploration agent definition | +| scout.md | Subagent | Scout/pathfinding agent definition | + +### src/resources/skills/ + +| Skill Directory | System Label(s) | Description | +|-----------------|-----------------|-------------| +| react-best-practices/ | Skills | React development patterns (62 files) | +| userinterface-wiki/ | Skills | UI/UX guidelines and component reference (155 files) | +| create-skill/ | Skills | Skill creation scaffolding and templates (25 files) | +| create-gsd-extension/ | Skills, Extensions | GSD extension scaffolding (22 files) | +| code-optimizer/ | Skills | Performance optimization techniques (16 files) | +| agent-browser/ | Skills, Browser Tools | Browser automation guidance (11 files) | +| github-workflows/ | Skills | GitHub Actions workflow patterns (10 files) | +| debug-like-expert/ | Skills | Advanced debugging techniques (6 files) | +| make-interfaces-feel-better/ | Skills | UI/UX improvement patterns (5 files) | +| accessibility/ | Skills | WCAG and accessibility standards | +| core-web-vitals/ | Skills | Web performance metrics guidance | +| web-quality-audit/ | Skills | Quality audit procedures | +| best-practices/ | Skills | General development best practices | +| frontend-design/ | Skills | Frontend design principles | +| lint/ | Skills | Code linting standards | +| review/ | Skills | Code review guidelines | +| test/ | Skills | Testing strategies and patterns | +| web-design-guidelines/ | Skills | Web design principles | + +--- + +## web/ — Web Frontend (Next.js) + +### App Shell & Navigation + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| web/app/layout.tsx | Web UI | Root Next.js layout with theme provider and font | +| web/app/page.tsx | Web UI | Entry page loading GSDAppShell | +| web/components/gsd/app-shell.tsx | Web UI | Main app shell — sidebar, panels, terminal, commands | +| web/components/gsd/sidebar.tsx | Web UI | Multi-panel sidebar with milestone explorer | +| web/components/gsd/status-bar.tsx | Web UI | Status bar with workspace state and metrics | + +### Main Views + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| web/components/gsd/dashboard.tsx | Web UI | Dashboard with workflow actions and metrics | +| web/components/gsd/chat-mode.tsx | Web UI | Chat interface for agent interaction | +| web/components/gsd/projects-view.tsx | Web UI | Project browser and selector | +| web/components/gsd/files-view.tsx | Web UI | File browser and explorer | +| web/components/gsd/activity-view.tsx | Web UI | Activity log and history view | +| web/components/gsd/roadmap.tsx | Web UI, GSD Workflow | Milestone roadmap visualization | +| web/components/gsd/visualizer-view.tsx | Web UI, Doctor/Diagnostics | Workflow visualization | +| web/components/gsd/project-welcome.tsx | Web UI | Welcome screen for new projects | +| web/components/gsd/knowledge-captures-panel.tsx | Web UI | Knowledge and capture management | + +### Terminal + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| web/components/gsd/terminal.tsx | Web UI | Terminal widget with input mode handling | +| web/components/gsd/shell-terminal.tsx | Web UI | Shell terminal with PTY integration | +| web/components/gsd/main-session-terminal.tsx | Web UI | Main session terminal display | +| web/components/gsd/dual-terminal.tsx | Web UI | Side-by-side terminal layout | + +### Commands & Dialogs + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| web/components/gsd/command-surface.tsx | Web UI, Commands | Command palette and slash command dispatcher | +| web/components/gsd/remaining-command-panels.tsx | Web UI, Commands | History, undo, export, cleanup panels | +| web/components/gsd/diagnostics-panels.tsx | Web UI, Doctor/Diagnostics | Doctor, forensics, skill health panels | +| web/components/gsd/settings-panels.tsx | Web UI, Config | Settings and preferences panels | +| web/components/gsd/guided-dialog.tsx | Web UI | Generic guided dialog component | +| web/components/gsd/update-banner.tsx | Web UI | Update notification banner | +| web/components/gsd/scope-badge.tsx | Web UI | Scope badge indicator | +| web/components/gsd/loading-skeletons.tsx | Web UI | Loading skeleton placeholders | +| web/components/gsd/code-editor.tsx | Web UI | Code editor display component | +| web/components/gsd/file-content-viewer.tsx | Web UI | File content viewer and previewer | +| web/components/gsd/focused-panel.tsx | Web UI | Focused panel layout component | + +### Onboarding + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| web/components/gsd/onboarding-gate.tsx | Web UI, Onboarding | Gate and orchestration for onboarding flow | +| web/components/gsd/onboarding/step-welcome.tsx | Web UI, Onboarding | Welcome step | +| web/components/gsd/onboarding/step-mode.tsx | Web UI, Onboarding | User mode selection step | +| web/components/gsd/onboarding/step-provider.tsx | Web UI, Onboarding | LLM provider selection step | +| web/components/gsd/onboarding/step-authenticate.tsx | Web UI, Onboarding, Auth/OAuth | Authentication step | +| web/components/gsd/onboarding/step-dev-root.tsx | Web UI, Onboarding | Dev root directory selection step | +| web/components/gsd/onboarding/step-project.tsx | Web UI, Onboarding | Project selection step | +| web/components/gsd/onboarding/step-remote.tsx | Web UI, Onboarding | Remote configuration step | +| web/components/gsd/onboarding/step-optional.tsx | Web UI, Onboarding | Optional settings step | +| web/components/gsd/onboarding/step-ready.tsx | Web UI, Onboarding | Ready confirmation step | +| web/components/gsd/onboarding/wizard-stepper.tsx | Web UI, Onboarding | Stepper progress indicator | + +### API Routes + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| web/app/api/boot/route.ts | API Routes, State Machine | Initial boot payload with project/workspace state | +| web/app/api/session/manage/route.ts | API Routes, Session Management | Session rename and management | +| web/app/api/session/browser/route.ts | API Routes, Session Management | Session browser listing | +| web/app/api/session/command/route.ts | API Routes, Session Management | Session command execution | +| web/app/api/session/events/route.ts | API Routes, Session Management | Session event streaming (SSE) | +| web/app/api/terminal/stream/route.ts | API Routes | PTY output streaming via SSE | +| web/app/api/terminal/input/route.ts | API Routes | Terminal input submission | +| web/app/api/terminal/resize/route.ts | API Routes | Terminal resize | +| web/app/api/terminal/sessions/route.ts | API Routes | Terminal session management | +| web/app/api/terminal/upload/route.ts | API Routes | File upload for terminal | +| web/app/api/bridge-terminal/stream/route.ts | API Routes, Web Mode | Bridge terminal output streaming | +| web/app/api/bridge-terminal/input/route.ts | API Routes, Web Mode | Bridge terminal input | +| web/app/api/bridge-terminal/resize/route.ts | API Routes, Web Mode | Bridge terminal resize | +| web/app/api/projects/route.ts | API Routes | Project discovery and listing | +| web/app/api/live-state/route.ts | API Routes, State Machine | Live workspace state updates | +| web/app/api/steer/route.ts | API Routes, Commands | Steering endpoint for agent direction | +| web/app/api/history/route.ts | API Routes, State Machine | History and metrics | +| web/app/api/undo/route.ts | API Routes, Commands | Undo operation | +| web/app/api/cleanup/route.ts | API Routes, Commands | Cleanup operation | +| web/app/api/export-data/route.ts | API Routes, Commands | Data export | +| web/app/api/knowledge/route.ts | API Routes, GSD Workflow | Knowledge base | +| web/app/api/hooks/route.ts | API Routes, GSD Workflow | Git hooks management | +| web/app/api/inspect/route.ts | API Routes, Doctor/Diagnostics | Inspection and analysis | +| web/app/api/doctor/route.ts | API Routes, Doctor/Diagnostics | Doctor diagnostic tool | +| web/app/api/forensics/route.ts | API Routes, Doctor/Diagnostics | Forensics analysis | +| web/app/api/skill-health/route.ts | API Routes, Doctor/Diagnostics | Skill health check | +| web/app/api/visualizer/route.ts | API Routes, Doctor/Diagnostics | Workflow visualization | +| web/app/api/preferences/route.ts | API Routes, Config | User preferences | +| web/app/api/settings-data/route.ts | API Routes, Config | Settings data | +| web/app/api/dev-mode/route.ts | API Routes, Config | Development mode toggle | +| web/app/api/captures/route.ts | API Routes, GSD Workflow | Knowledge captures | +| web/app/api/browse-directories/route.ts | API Routes | Directory browsing | +| web/app/api/files/route.ts | API Routes, Tool System | File system access | +| web/app/api/git/route.ts | API Routes, Tool System | Git operations | +| web/app/api/onboarding/route.ts | API Routes, Onboarding | Onboarding data | +| web/app/api/recovery/route.ts | API Routes, Doctor/Diagnostics | Recovery operations | +| web/app/api/remote-questions/route.ts | API Routes, Remote Questions | Remote question handling | +| web/app/api/shutdown/route.ts | API Routes | Graceful shutdown | +| web/app/api/update/route.ts | API Routes, CLI | Update check | + +### Library & State + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| web/lib/auth.ts | Auth/OAuth | Client-side auth token management from URL fragment | +| web/lib/gsd-workspace-store.tsx | State Machine | Global workspace state store with external store | +| web/lib/project-store-manager.tsx | State Machine | Multi-project store manager with SSE lifecycle | +| web/lib/shutdown-gate.ts | State Machine | Graceful shutdown coordination | +| web/lib/browser-slash-command-dispatch.ts | Commands | Slash command dispatch | +| web/lib/workflow-actions.ts | GSD Workflow | Primary workflow action derivation logic | +| web/lib/workflow-action-execution.ts | GSD Workflow | Workflow action execution handler | +| web/lib/command-surface-contract.ts | Commands | Command surface request/response contract types | +| web/lib/pty-manager.ts | Web UI | Server-side PTY spawning and session management | +| web/lib/pty-chat-parser.ts | Web UI | PTY output parsing for chat display | +| web/lib/remaining-command-types.ts | Web UI | Browser-safe types for command surfaces | +| web/lib/knowledge-captures-types.ts | GSD Workflow | Knowledge entry and captures types | +| web/lib/diagnostics-types.ts | Doctor/Diagnostics | Diagnostics panel types | +| web/lib/settings-types.ts | Config | Settings and preferences types | +| web/lib/visualizer-types.ts | Doctor/Diagnostics | Workflow visualizer types | +| web/lib/session-browser-contract.ts | Session Management | Session browser contract types | +| web/lib/git-summary-contract.ts | Tool System | Git summary contract types | +| web/lib/utils.ts | Web UI | Common utility functions | +| web/lib/project-url.ts | Web UI | Project URL parsing and construction | +| web/lib/workspace-status.ts | Web UI, State Machine | Workspace status derivation | +| web/lib/image-utils.ts | Image Processing | Image handling and processing utilities | +| web/lib/use-editor-font-size.ts | Web UI | Editor font size preference hook | +| web/lib/use-terminal-font-size.ts | Web UI | Terminal font size preference hook | +| web/lib/use-user-mode.ts | Web UI | User mode hook | +| web/hooks/use-mobile.ts | Web UI | Mobile viewport detection hook | +| web/hooks/use-toast.ts | Web UI | Toast notification hook | +| web/components/theme-provider.tsx | Web UI | Theme provider for dark/light modes | +| web/components/ui/* (50+ files) | Web UI | Shadcn/ui base component library | + +--- + +## vscode-extension/ — VS Code Extension + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| vscode-extension/src/extension.ts | VS Code Extension | Extension activation, client management, command registration | +| vscode-extension/src/gsd-client.ts | VS Code Extension, MCP Server/Client | RPC client for GSD agent communication | +| vscode-extension/src/chat-participant.ts | VS Code Extension | Chat participant for @gsd command | +| vscode-extension/src/sidebar.ts | VS Code Extension | Sidebar webview provider with status display | + +--- + +## studio/ — Electron Desktop App + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| studio/electron.vite.config.ts | Studio App, Build System | Electron Vite build configuration | +| studio/src/main/index.ts | Studio App | Electron main process window creation | +| studio/src/preload/index.ts | Studio App | Context isolation preload for IPC bridge | +| studio/src/preload/index.d.ts | Studio App | Preload bridge type definitions | +| studio/src/renderer/src/main.tsx | Studio App | React renderer entry point | +| studio/src/renderer/src/App.tsx | Studio App | Main app component | +| studio/src/renderer/src/lib/theme/tokens.ts | Studio App | Design tokens (colors, fonts, sizes) | + +--- + +## native/ — Rust Engine + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| native/crates/engine/src/lib.rs | Native/Rust Tools | N-API entry point exposing all Rust modules | +| native/crates/engine/src/grep.rs | File Search, Native/Rust Tools | Ripgrep-backed regex search with context/globbing | +| native/crates/engine/src/glob.rs | File Search, Native/Rust Tools | Glob-pattern FS discovery with gitignore + scan cache | +| native/crates/engine/src/fd.rs | File Search, Native/Rust Tools | Fuzzy file discovery for autocomplete/@-mentions | +| native/crates/engine/src/highlight.rs | Syntax Highlighting, Native/Rust Tools | Syntect-backed ANSI syntax highlighting | +| native/crates/engine/src/ast.rs | AST, Native/Rust Tools | Linker shim for AST N-API registrations | +| native/crates/engine/src/diff.rs | Text Processing, Native/Rust Tools | Fuzzy matching, Unicode normalization, unified diffs | +| native/crates/engine/src/image.rs | Image Processing, Native/Rust Tools | Image decode/encode and resize | +| native/crates/engine/src/html.rs | Text Processing, Native/Rust Tools | HTML to Markdown conversion | +| native/crates/engine/src/text.rs | Text Processing, Native/Rust Tools | ANSI-aware text measurement and slicing | +| native/crates/engine/src/truncate.rs | Text Processing, Native/Rust Tools | Line-boundary-aware output truncation | +| native/crates/engine/src/ps.rs | Native/Rust Tools | Cross-platform process tree management | +| native/crates/engine/src/clipboard.rs | Native/Rust Tools | Clipboard read/write for text and images | +| native/crates/engine/src/json_parse.rs | Text Processing, Native/Rust Tools | Streaming JSON parser with partial recovery | +| native/crates/engine/src/gsd_parser.rs | GSD Workflow, Native/Rust Tools | .gsd/ directory file parser (markdown, frontmatter) | +| native/crates/engine/src/ttsr.rs | TTSR, Native/Rust Tools | TTSR regex engine with compiled RegexSet | +| native/crates/engine/src/stream_process.rs | Text Processing, Native/Rust Tools | Bash stream processor (UTF-8, ANSI strip, binary) | +| native/crates/engine/src/xxhash.rs | Native/Rust Tools | xxHash32 for hashline edit tool | +| native/crates/engine/src/git.rs | Native/Rust Tools | Native git operations via libgit2 | +| native/crates/engine/src/fs_cache.rs | File Search, Native/Rust Tools | TTL-based FS scan cache with explicit invalidation | +| native/crates/engine/src/glob_util.rs | File Search, Native/Rust Tools | Shared glob-pattern helpers | +| native/crates/engine/src/task.rs | Native/Rust Tools | Blocking work on libuv thread pool with cancellation | +| native/crates/engine/build.rs | Build System | Cargo build script for napi-build compilation | +| native/crates/grep/src/lib.rs | File Search, Native/Rust Tools | Ripgrep search library (in-memory and on-disk) | +| native/crates/ast/src/lib.rs | AST, Native/Rust Tools | AST-aware structural search and rewrite engine | +| native/crates/ast/src/ast.rs | AST, Native/Rust Tools | ast-grep integration for structural code search | +| native/crates/ast/src/language/mod.rs | AST, Native/Rust Tools | Vendored language defs and tree-sitter bindings | +| native/crates/ast/src/language/parsers.rs | AST, Native/Rust Tools | Pre-compiled tree-sitter parsers (50+ languages) | + +## packages/native/src/ — Node.js Rust Bindings + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| packages/native/src/native.ts | Native/Rust Tools, Node.js Bindings | Native addon loader with platform fallback | +| packages/native/src/grep/index.ts | File Search, Node.js Bindings | Ripgrep wrapper for regex search | +| packages/native/src/fd/index.ts | File Search, Node.js Bindings | Fuzzy file discovery wrapper | +| packages/native/src/highlight/index.ts | Syntax Highlighting, Node.js Bindings | Syntax highlighting wrapper | +| packages/native/src/image/index.ts | Image Processing, Node.js Bindings | Image processing wrapper | +| packages/native/src/html/index.ts | Text Processing, Node.js Bindings | HTML to Markdown wrapper | +| packages/native/src/diff/index.ts | Text Processing, Node.js Bindings | Text diffing wrapper | +| packages/native/src/ps/index.ts | Native/Rust Tools, Node.js Bindings | Process tree management wrapper | +| packages/native/src/truncate/index.ts | Text Processing, Node.js Bindings | Output truncation wrapper | +| packages/native/src/json-parse/index.ts | Text Processing, Node.js Bindings | JSON parsing wrapper | +| packages/native/src/stream-process/index.ts | Text Processing, Node.js Bindings | Stream processing wrapper | +| packages/native/src/ttsr/index.ts | TTSR, Node.js Bindings | TTSR regex engine wrapper | + +--- + +## tests/ — Test Suite + +| File / Directory | System Label(s) | Description | +|------------------|-----------------|-------------| +| tests/smoke/run.ts | Integration Tests | Test runner for smoke tests | +| tests/smoke/test-help.ts | Integration Tests | Smoke test for help command | +| tests/smoke/test-init.ts | Integration Tests | Smoke test for initialization | +| tests/smoke/test-version.ts | Integration Tests | Smoke test for version command | +| tests/fixtures/run.ts | Integration Tests | Fixture-based test harness with recording replay | +| tests/fixtures/provider.ts | Integration Tests | Fixture provider and replayer for LLM turns | +| tests/fixtures/record.ts | Integration Tests | Recording fixture capture | +| tests/fixtures/recordings/*.json | Integration Tests | Pre-recorded LLM agent interaction fixtures | +| tests/live/run.ts | Integration Tests | Live API roundtrip test runner | +| tests/live/test-anthropic-roundtrip.ts | Integration Tests, AI Providers | Live Anthropic API integration test | +| tests/live/test-openai-roundtrip.ts | Integration Tests, AI Providers | Live OpenAI API integration test | +| tests/live-regression/run.ts | Integration Tests | Live regression test runner | +| tests/repro-worktree-bug/*.mjs | Integration Tests, Worktree | Worktree bug reproduction scripts | + +--- + +## scripts/ — Build & Utility + +| File | System Label(s) | Description | +|------|-----------------|-------------| +| scripts/dev.js | Build System | Dev supervisor — tsc and resource watcher | +| scripts/dev-cli.js | Build System | CLI development mode runner | +| scripts/watch-resources.js | Build System | Resource file watcher for hot reload | +| scripts/bump-version.mjs | Build System | Version bumper for package.json and platform packages | +| scripts/sync-pkg-version.cjs | Build System | Sync pkg/package.json with workspace version | +| scripts/copy-resources.cjs | Build System | Resource file copier for distribution | +| scripts/copy-export-html.cjs | Build System | HTML export asset copier | +| scripts/copy-themes.cjs | Build System | Theme file copier | +| scripts/link-workspace-packages.cjs | Build System | Workspace package symlink manager | +| scripts/ensure-workspace-builds.cjs | Build System | Postinstall build checker | +| scripts/build-web-if-stale.cjs | Build System | Conditional web build trigger | +| scripts/stage-web-standalone.cjs | Build System | Web standalone staging | +| scripts/generate-changelog.mjs | Build System | Changelog generator from commits | +| scripts/update-changelog.mjs | Build System | Changelog updater | +| scripts/version-stamp.mjs | Build System | Version timestamp generator | +| scripts/validate-pack.sh | Build System | Package validation script | +| scripts/validate-pack.js | Build System | Package validation (Node.js) | +| scripts/install-pi-global.js | Build System | Global installation helper | +| scripts/uninstall-pi-global.js | Build System | Global uninstallation helper | +| scripts/install-hooks.sh | Build System, GSD Workflow | Git hook installer | +| scripts/secret-scan.sh | Build System, Auth/OAuth | Secret scanning for credentials | +| scripts/docs-prompt-injection-scan.sh | Build System | Prompt injection detection in docs | +| scripts/check-skill-references.mjs | Build System, Skills | Skill reference validator | +| scripts/preview-dashboard.ts | Web Mode | Dashboard preview server | +| scripts/ci_monitor.cjs | Build System | CI monitoring dashboard | +| scripts/recover-gsd-1364.sh | Build System, Migration | Recovery script for issue #1364 | +| scripts/recover-gsd-1364.ps1 | Build System, Migration | Recovery script for issue #1364 (PowerShell) | +| scripts/recover-gsd-1668.sh | Build System, Migration | Recovery script for issue #1668 | +| scripts/recover-gsd-1668.ps1 | Build System, Migration | Recovery script for issue #1668 (PowerShell) | + +--- + +## System → File Reverse Index + +Quick lookup: which files are part of each system? + +| System | Key Files (abbreviated) | +|--------|------------------------| +| **Agent Core** | pi-agent-core/src/*, pi-coding-agent/src/core/agent-session.ts, agent-loop.ts, agent.ts, event-bus.ts, sdk.ts | +| **AI Providers** | pi-ai/src/providers/*, pi-ai/src/stream.ts, pi-ai/src/models*.ts | +| **API Routes** | web/app/api/**/*.ts | +| **AST** | native/crates/ast/*, packages/native/src/ast/ | +| **Async Jobs** | src/resources/extensions/async-jobs/* | +| **Auth / OAuth** | pi-ai/src/utils/oauth/*, src/web/web-auth-storage.ts, core/auth-storage.ts, src/pi-migration.ts, aws-auth/index.ts, web/lib/auth.ts | +| **Auto Engine** | src/resources/extensions/gsd/auto*.ts, gsd/auto-loop.ts, gsd/auto-supervisor.ts, gsd/unit-runtime.ts | +| **Bg Shell** | src/resources/extensions/bg-shell/* | +| **Browser Tools** | src/resources/extensions/browser-tools/* | +| **Build System** | scripts/*, native/crates/engine/build.rs | +| **CLI** | src/cli.ts, src/cli-web-branch.ts, src/help-text.ts, src/update*.ts, pi-coding-agent/src/cli.ts, src/worktree-cli.ts | +| **CMux** | src/resources/extensions/cmux/index.ts | +| **Commands** | gsd/commands*.ts, gsd/exit-command.ts, gsd/undo.ts, gsd/kill.ts, pi-coding-agent/src/core/slash-commands.ts | +| **Compaction** | pi-coding-agent/src/core/compaction*.ts, core/compaction/* | +| **Config** | src/app-paths.ts, src/models-resolver.ts, src/remote-questions-config.ts, src/wizard.ts, core/defaults.ts, core/constants.ts, config.ts | +| **Context7** | src/resources/extensions/context7/index.ts | +| **Doctor / Diagnostics** | gsd/doctor*.ts, gsd/collision-diagnostics.ts, core/diagnostics.ts, web/lib/diagnostics-types.ts, web/app/api/doctor/*, forensics/* | +| **Event System** | pi-coding-agent/src/core/event-bus.ts, gsd/auto-observability.ts | +| **Extension Registry** | src/extension-discovery.ts, src/extension-registry.ts, src/bundled-extension-paths.ts | +| **Extensions** | pi-coding-agent/src/core/extensions/*, src/resource-loader.ts | +| **File Search** | native/crates/engine/src/grep.rs, glob.rs, fd.rs, fs_cache.rs, packages/native/src/grep/*, fd/*, core/tools/grep.ts, find.ts | +| **GSD Workflow** | src/resources/extensions/gsd/* (non-auto), gsd/reports.ts, gsd/notifications.ts, gsd/prompts/*, gsd/workflow-templates/* | +| **Google Search** | src/resources/extensions/google-search/index.ts | +| **Headless Mode** | src/headless*.ts | +| **Image Processing** | native/crates/engine/src/image.rs, packages/native/src/image/*, utils/image-*.ts, web/lib/image-utils.ts | +| **Integration Tests** | tests/**/* | +| **Loader / Bootstrap** | src/loader.ts, src/resource-loader.ts, src/tool-bootstrap.ts, src/bundled-resource-path.ts, gsd/bootstrap/* | +| **LSP** | pi-coding-agent/src/core/lsp/* | +| **Mac Tools** | src/resources/extensions/mac-tools/* | +| **MCP Server/Client** | src/mcp-server.ts, src/resources/extensions/mcp-client/index.ts, vscode-extension/src/gsd-client.ts, modes/rpc/* | +| **Memory Extension** | pi-coding-agent/src/resources/extensions/memory/* | +| **Migration** | gsd/migrate/*, src/pi-migration.ts, pi-coding-agent/src/migrations.ts, scripts/recover-*.sh | +| **Modes** | pi-coding-agent/src/modes/* | +| **Model System** | pi-coding-agent/src/core/model-*.ts, pi-ai/src/models*.ts, pi-ai/src/api-registry.ts, gsd/model-router.ts | +| **Native / Rust Tools** | native/crates/engine/src/* | +| **Node.js Bindings** | packages/native/src/* | +| **Onboarding** | src/onboarding.ts, src/wizard.ts, web/components/gsd/onboarding/*, web/app/api/onboarding/* | +| **Permissions** | core/extensions/project-trust.ts, core/auth-storage.ts | +| **Remote Questions** | src/resources/extensions/remote-questions/* | +| **Search the Web** | src/resources/extensions/search-the-web/* | +| **Session Management** | pi-coding-agent/src/core/session-manager.ts, core/settings-manager.ts, web/app/api/session/* | +| **Skills** | src/resources/skills/*, gsd/skill-telemetry.ts, gsd/preferences-skills.ts, core/skills.ts | +| **Slash Commands** | src/resources/extensions/slash-commands/* | +| **State Machine** | gsd/state.ts, gsd/history.ts, gsd/json-persistence.ts, gsd/memory-store.ts, gsd/reactive-graph.ts, core/agent-session.ts, web/lib/gsd-workspace-store.tsx | +| **Studio App** | studio/* | +| **Subagent** | src/resources/extensions/subagent/*, src/resources/agents/* | +| **Syntax Highlighting** | native/crates/engine/src/highlight.rs, packages/native/src/highlight/* | +| **Text Processing** | native/crates/engine/src/diff.rs, html.rs, text.rs, truncate.rs, json_parse.rs, stream_process.rs | +| **Tool System** | pi-coding-agent/src/core/tools/*, core/bash-executor.ts, core/exec.ts | +| **TTSR** | src/resources/extensions/ttsr/*, native/crates/engine/src/ttsr.rs, packages/native/src/ttsr/* | +| **TUI Components** | packages/pi-tui/src/*, pi-coding-agent/src/modes/interactive/components/*, pi-coding-agent/src/modes/interactive/controllers/* | +| **Universal Config** | src/resources/extensions/universal-config/* | +| **Voice** | src/resources/extensions/voice/* | +| **VS Code Extension** | vscode-extension/src/* | +| **Web Mode** | src/web/*.ts, src/web-mode.ts | +| **Web UI** | web/app/*.tsx, web/components/*, web/hooks/*, web/lib/* | +| **Worktree** | src/worktree-cli.ts, src/worktree-name-gen.ts, gsd/worktree*.ts, tests/repro-worktree-bug/* | diff --git a/docs/README.md b/docs/README.md index 080a5eaf7..c37b303c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Welcome to the GSD documentation. This covers everything from getting started to | [Commands Reference](./commands.md) | All commands, keyboard shortcuts, and CLI flags | | [Remote Questions](./remote-questions.md) | Discord and Slack integration for headless auto-mode | | [Configuration](./configuration.md) | Preferences, model selection, git settings, and token profiles | +| [Custom Models](./custom-models.md) | Add custom providers (Ollama, vLLM, LM Studio, proxies) via models.json | | [Token Optimization](./token-optimization.md) | Token profiles, context compression, complexity routing, and adaptive learning (v2.17) | | [Dynamic Model Routing](./dynamic-model-routing.md) | Complexity-based model selection, cost tables, escalation, and budget pressure (v2.19) | | [Captures & Triage](./captures-triage.md) | Fire-and-forget thought capture during auto-mode with automated triage (v2.19) | @@ -21,7 +22,8 @@ Welcome to the GSD documentation. This covers everything from getting started to | [Working in Teams](./working-in-teams.md) | Unique milestone IDs, `.gitignore` setup, and shared planning artifacts | | [Skills](./skills.md) | Bundled skills, skill discovery, and custom skill authoring | | [Migration from v1](./migration.md) | Migrating `.planning` directories from the original GSD | -| [Troubleshooting](./troubleshooting.md) | Common issues, `/gsd doctor`, and recovery procedures | +| [Troubleshooting](./troubleshooting.md) | Common issues, `/gsd doctor` (real-time visibility v2.40), `/gsd forensics` (full debugger v2.40), and recovery procedures | +| [Web Interface](./web-interface.md) | Browser-based project management with `pi --web` (v2.41) | | [VS Code Extension](../vscode-extension/README.md) | Chat participant, sidebar dashboard, and RPC integration for VS Code | ## Architecture & Internals diff --git a/docs/auto-mode.md b/docs/auto-mode.md index 582729f92..5d2c47e3a 100644 --- a/docs/auto-mode.md +++ b/docs/auto-mode.md @@ -87,13 +87,27 @@ When context usage reaches 70%, GSD sends a wrap-up signal to the agent, nudging Commits are generated from task summaries — not generic "complete task" messages. Each commit message reflects what was actually built, giving clean `git log` output that reads like a changelog. -### Stuck Detection +### Stuck Detection (v2.39) -If the same unit dispatches twice (the LLM didn't produce the expected artifact), GSD retries once with a deep diagnostic prompt. If it fails again, auto mode stops with the exact file it expected, so you can intervene. +GSD uses a sliding-window analysis to detect stuck loops. Instead of a simple "same unit dispatched twice" counter, the detector examines recent dispatch history for repeated patterns — catching cycles like A→B→A→B as well as single-unit repeats. On detection, GSD retries once with a deep diagnostic prompt. If it fails again, auto mode stops with the exact file it expected, so you can intervene. -### Post-Mortem Investigation +The sliding-window approach reduces false positives on legitimate retries (e.g., verification failures that self-correct) while catching genuine stuck loops faster. -When auto mode fails or produces unexpected results, `/gsd forensics` provides structured post-mortem analysis. It inspects activity logs, crash locks, and session state to identify root causes — whether the failure was a model error, missing context, a stuck loop, or a broken tool call. See [Troubleshooting](./troubleshooting.md) for more on diagnosing issues. +### Post-Mortem Investigation (v2.40) + +`/gsd forensics` is a full-access GSD debugger for post-mortem analysis of auto-mode failures. It provides: + +- **Anomaly detection** — structured identification of stuck loops, cost spikes, timeouts, missing artifacts, and crashes with severity levels +- **Unit traces** — last 10 unit executions with error details and execution times +- **Metrics analysis** — cost, token counts, and execution time breakdowns +- **Doctor integration** — includes structural health issues from `/gsd doctor` +- **LLM-guided investigation** — an agent session with full tool access to investigate root causes + +``` +/gsd forensics [optional problem description] +``` + +See [Troubleshooting](./troubleshooting.md) for more on diagnosing issues. ### Timeout Supervision @@ -162,6 +176,38 @@ Generate manually anytime with `/gsd export --html`, or generate reports for all v2.28 hardens auto-mode reliability with multiple safeguards: atomic file writes prevent corruption on crash, OAuth fetch timeouts (30s) prevent indefinite hangs, RPC subprocess exit is detected and reported, and blob garbage collection prevents unbounded disk growth. Combined with the existing crash recovery and headless auto-restart, auto-mode is designed for true "fire and forget" overnight execution. +### Pipeline Architecture (v2.40) + +The auto-loop is structured as a linear phase pipeline rather than recursive dispatch. Each iteration flows through explicit stages: + +1. **Pre-Dispatch** — validate state, check guards, resolve model preferences +2. **Dispatch** — execute the unit with a focused prompt +3. **Post-Unit** — close out the unit, update caches, run cleanup +4. **Verification** — optional validation gate (lint, test, etc.) +5. **Stuck Detection** — sliding-window pattern analysis + +This linear flow is easier to debug, uses less memory (no recursive call stack), and provides cleaner error recovery since each phase has well-defined entry and exit conditions. + +### Real-Time Health Visibility (v2.40) + +Doctor issues (from `/gsd doctor`) now surface in real time across three places: + +- **Dashboard widget** — health indicator with issue count and severity +- **Workflow visualizer** — issues shown in the status panel +- **HTML reports** — health section with all issues at report generation time + +Issues are classified by severity: `error` (blocks auto-mode), `warning` (non-blocking), and `info` (advisory). Auto-mode checks health at dispatch time and can pause on critical issues. + +### Skill Activation in Prompts (v2.39) + +Configured skills are automatically resolved and injected into dispatch prompts. The agent receives an "Available Skills" block listing skills that match the current context, based on: + +- `always_use_skills` — always included +- `prefer_skills` — included with preference indicator +- `skill_rules` — conditional activation based on `when` clauses + +See [Configuration](./configuration.md) for skill routing preferences. + ## Controlling Auto Mode ### Start diff --git a/docs/ci-cd-pipeline.md b/docs/ci-cd-pipeline.md index 623e62299..80410d124 100644 --- a/docs/ci-cd-pipeline.md +++ b/docs/ci-cd-pipeline.md @@ -70,6 +70,34 @@ docker run --rm -v $(pwd):/workspace ghcr.io/gsd-build/gsd-pi:latest --version **CI optimization (v2.38):** GitHub Actions minutes were reduced ~60-70% (~10k → ~3-4k/month) through workflow consolidation and caching improvements. +**Pipeline optimization (v2.41):** +- **Shallow clones** — CI lint and build jobs use `fetch-depth: 1` or `fetch-depth: 2` instead of full history, saving ~30-60s per job +- **npm cache in pipeline** — dev-publish, test-verify, and prod-release now use `cache: 'npm'` on setup-node, saving ~1-2 min per job on repeat runs +- **Exponential backoff** — npm registry propagation waits in `build-native.yml` replaced hardcoded `sleep 30` + fixed 15s retries with exponential backoff (5s → 10s → 20s → 30s cap), typically finishing in <15s when the registry is fast +- **Security hardening** — pipeline.yml moved `${{ }}` expressions from `run:` blocks to `env:` variables to prevent command injection vectors +### Docs-Only PR Detection (v2.41) + +CI automatically detects when a PR contains only documentation changes (`.md` files and `docs/` content). When docs-only: + +- **Skipped:** `build`, `windows-portability` (no code to compile or test) +- **Still runs:** `lint` (secret scanning, `.gsd/` check), `docs-check` (prompt injection scan) + +This saves CI minutes on documentation PRs while still enforcing security checks. + +### Prompt Injection Scan (v2.41) + +The `docs-check` job runs `scripts/docs-prompt-injection-scan.sh` on every PR that touches markdown files. It scans documentation prose (excluding fenced code blocks) for patterns that could manipulate LLM behavior when docs are ingested as context: + +- **System prompt markers** — ``, `<|im_start|>system`, `[SYSTEM]:` +- **Role/instruction overrides** — `ignore previous instructions`, `you are now`, `new instructions:` +- **Hidden HTML directives** — `'); lines.push(''); - lines.push('| # | When | Scope | Decision | Choice | Rationale | Revisable? |'); - lines.push('|---|------|-------|----------|--------|-----------|------------|'); + lines.push('| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |'); + lines.push('|---|------|-------|----------|--------|-----------|------------|---------|'); for (const d of decisions) { // Escape pipe characters within cell values to preserve table structure @@ -48,6 +48,7 @@ export function generateDecisionsMd(decisions: Decision[]): string { d.choice, d.rationale, d.revisable, + d.made_by ?? 'agent', ].map(cell => (cell ?? '').replace(/\|/g, '\\|')); lines.push(`| ${cells.join(' | ')} |`); @@ -181,6 +182,7 @@ export interface SaveDecisionFields { rationale: string; revisable?: string; when_context?: string; + made_by?: import('./types.js').DecisionMadeBy; } /** @@ -205,6 +207,7 @@ export async function saveDecisionToDb( choice: fields.choice, rationale: fields.rationale, revisable: fields.revisable ?? 'Yes', + made_by: fields.made_by ?? 'agent', superseded_by: null, }); @@ -222,6 +225,7 @@ export async function saveDecisionToDb( choice: row['choice'] as string, rationale: row['rationale'] as string, revisable: row['revisable'] as string, + made_by: (row['made_by'] as string as import('./types.js').DecisionMadeBy) ?? 'agent', superseded_by: (row['superseded_by'] as string) ?? null, })); } diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 9401dae9b..9a0c159eb 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -69,7 +69,7 @@ export interface ProjectSignals { // ─── Project File Markers ─────────────────────────────────────────────────────── -const PROJECT_FILES = [ +export const PROJECT_FILES = [ "package.json", "Cargo.toml", "go.mod", diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index 717b711f9..e0f065fea 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -56,6 +56,7 @@ export function getPriorSliceCompletionBlocker( for (const mid of milestoneIds) { if (resolveMilestoneFile(base, mid, "PARKED")) continue; + if (resolveMilestoneFile(base, mid, "SUMMARY")) continue; // Read from disk (working tree) — always has the latest state const roadmapContent = readRoadmapFromDisk(base, mid); @@ -70,14 +71,34 @@ export function getPriorSliceCompletionBlocker( continue; } - const targetIndex = slices.findIndex((slice) => slice.id === targetSid); - if (targetIndex === -1) return null; + const targetSlice = slices.find((slice) => slice.id === targetSid); + if (!targetSlice) return null; - const incomplete = slices - .slice(0, targetIndex) - .find((slice) => !slice.done); - if (incomplete) { - return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete.`; + // Dependency-aware ordering: if the target slice declares dependencies, + // only require those specific slices to be complete — not all positionally + // earlier slices. This prevents deadlocks when a positionally-earlier + // slice depends on a positionally-later one (e.g. S05 depends_on S06). + // + // When the target has NO declared dependencies, fall back to the original + // positional ordering for backward compatibility. + if (targetSlice.depends.length > 0) { + const sliceMap = new Map(slices.map((s) => [s.id, s])); + for (const depId of targetSlice.depends) { + const dep = sliceMap.get(depId); + if (dep && !dep.done) { + return `Cannot dispatch ${unitType} ${unitId}: dependency slice ${targetMid}/${depId} is not complete.`; + } + // If dep is not found in this milestone's slices, ignore it — + // it may be a cross-milestone reference handled elsewhere. + } + } else { + const targetIndex = slices.findIndex((slice) => slice.id === targetSid); + const incomplete = slices + .slice(0, targetIndex) + .find((slice) => !slice.done); + if (incomplete) { + return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete.`; + } } } diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts index b62c6ba87..64eb0a921 100644 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ b/src/resources/extensions/gsd/doctor-checks.ts @@ -2,18 +2,21 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmSync, import { basename, dirname, join, sep } from "node:path"; import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js"; +import { readRepoMeta, externalProjectsRoot } from "./repo-identity.js"; import { loadFile, parseRoadmap } from "./files.js"; import { resolveMilestoneFile, milestonesDir, gsdRoot, resolveGsdRootFile, relGsdRootFile } from "./paths.js"; import { deriveState, isMilestoneComplete } from "./state.js"; import { saveFile } from "./files.js"; import { listWorktrees, resolveGitDir, worktreesDir } from "./worktree-manager.js"; import { abortAndReset } from "./git-self-heal.js"; -import { RUNTIME_EXCLUSION_PATHS, readIntegrationBranch } from "./git-service.js"; -import { nativeIsRepo, nativeBranchExists, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js"; +import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch } from "./git-service.js"; +import { nativeIsRepo, nativeBranchExists, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js"; import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js"; import { ensureGitignore } from "./gitignore.js"; +import { getAllWorktreeHealth } from "./worktree-health.js"; import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js"; import { recoverFailedMigration } from "./migrate-external.js"; +import { loadEffectiveGSDPreferences } from "./preferences.js"; export async function checkGitHealth( basePath: string, @@ -201,16 +204,30 @@ export async function checkGitHealth( // ── Legacy slice branches ────────────────────────────────────────────── try { - const branchList = nativeBranchList(basePath, "gsd/*/*"); + const branchList = nativeBranchList(basePath, "gsd/*/*") + .filter((branch) => !branch.startsWith("gsd/quick/")); if (branchList.length > 0) { issues.push({ severity: "info", code: "legacy_slice_branches", scope: "project", unitId: "project", - message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture). Delete with: git branch -D ${branchList.join(" ")}`, - fixable: false, + message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture).`, + fixable: true, }); + + if (shouldFix("legacy_slice_branches")) { + let deleted = 0; + for (const branch of branchList) { + try { + nativeBranchDelete(basePath, branch, true); + deleted++; + } catch { /* skip branches that can't be deleted */ } + } + if (deleted > 0) { + fixesApplied.push(`deleted ${deleted} legacy slice branch(es)`); + } + } } } catch { // git branch list failed — skip @@ -222,17 +239,34 @@ export async function checkGitHealth( // and causes the next merge operation to fail silently. try { const state = await deriveState(basePath); + const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {}; for (const milestone of state.registry) { if (milestone.status === "complete") continue; - const integrationBranch = readIntegrationBranch(basePath, milestone.id); - if (!integrationBranch) continue; // No stored branch — skip (not yet set) - if (!nativeBranchExists(basePath, integrationBranch)) { + const resolution = resolveMilestoneIntegrationBranch(basePath, milestone.id, gitPrefs); + if (!resolution.recordedBranch) continue; // No stored branch — skip (not yet set) + if (resolution.status === "fallback" && resolution.effectiveBranch) { + issues.push({ + severity: "warning", + code: "integration_branch_missing", + scope: "milestone", + unitId: milestone.id, + message: resolution.reason, + fixable: true, + }); + if (shouldFix("integration_branch_missing")) { + writeIntegrationBranch(basePath, milestone.id, resolution.effectiveBranch); + fixesApplied.push(`updated integration branch for ${milestone.id} to "${resolution.effectiveBranch}"`); + } + continue; + } + + if (resolution.status === "missing") { issues.push({ severity: "error", code: "integration_branch_missing", scope: "milestone", unitId: milestone.id, - message: `Milestone ${milestone.id} recorded integration branch "${integrationBranch}" but that branch no longer exists in git. Merge-back will fail.`, + message: resolution.reason, fixable: false, }); } @@ -248,15 +282,23 @@ export async function checkGitHealth( try { const wtDir = worktreesDir(basePath); if (existsSync(wtDir)) { + // Resolve symlinks and normalize separators so that symlinked .gsd + // paths (e.g. ~/.gsd/projects//worktrees/…) match the paths + // returned by `git worktree list`. + const normalizePath = (p: string): string => { + try { p = realpathSync(p); } catch { /* path may not exist */ } + return p.replaceAll("\\", "/"); + }; const registeredPaths = new Set( - nativeWorktreeList(basePath).map(entry => entry.path), + nativeWorktreeList(basePath).map(entry => normalizePath(entry.path)), ); for (const entry of readdirSync(wtDir)) { const fullPath = join(wtDir, entry); try { if (!statSync(fullPath).isDirectory()) continue; } catch { continue; } - if (!registeredPaths.has(fullPath)) { + const normalizedFullPath = normalizePath(fullPath); + if (!registeredPaths.has(normalizedFullPath)) { issues.push({ severity: "warning", code: "worktree_directory_orphaned", @@ -279,6 +321,82 @@ export async function checkGitHealth( } catch { // Non-fatal — orphaned worktree directory check failed } + + // ── Worktree lifecycle checks ────────────────────────────────────────── + // Check GSD-managed worktrees for: merged branches, stale work, dirty + // state, and unpushed commits. Only worktrees under .gsd/worktrees/. + try { + const healthStatuses = getAllWorktreeHealth(basePath); + const cwd = process.cwd(); + + for (const health of healthStatuses) { + const wt = health.worktree; + const isCwd = wt.path === cwd || cwd.startsWith(wt.path + sep); + + // Branch fully merged into main — safe to remove + if (health.mergedIntoMain) { + issues.push({ + severity: "info", + code: "worktree_branch_merged", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" (branch ${wt.branch}) is fully merged into main${health.safeToRemove ? " — safe to remove" : ""}`, + fixable: health.safeToRemove, + }); + + if (health.safeToRemove && shouldFix("worktree_branch_merged") && !isCwd) { + try { + const { removeWorktree } = await import("./worktree-manager.js"); + removeWorktree(basePath, wt.name, { deleteBranch: true, branch: wt.branch }); + fixesApplied.push(`removed merged worktree "${wt.name}" and deleted branch ${wt.branch}`); + } catch { + fixesApplied.push(`failed to remove merged worktree "${wt.name}"`); + } + } + // If merged, skip the stale/dirty/unpushed checks — they're irrelevant + continue; + } + + // Stale: no commits in N days, not merged + if (health.stale) { + const days = Math.floor(health.lastCommitAgeDays); + issues.push({ + severity: "warning", + code: "worktree_stale", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has had no commits in ${days} day${days === 1 ? "" : "s"}`, + fixable: false, + }); + } + + // Dirty: uncommitted changes in a worktree (only flag on stale worktrees to avoid noise) + if (health.dirty && health.stale) { + issues.push({ + severity: "warning", + code: "worktree_dirty", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has ${health.dirtyFileCount} uncommitted file${health.dirtyFileCount === 1 ? "" : "s"} and is stale`, + fixable: false, + }); + } + + // Unpushed: commits not on any remote (only flag on stale worktrees to avoid noise) + if (health.unpushedCommits > 0 && health.stale) { + issues.push({ + severity: "warning", + code: "worktree_unpushed", + scope: "project", + unitId: wt.name, + message: `Worktree "${wt.name}" has ${health.unpushedCommits} unpushed commit${health.unpushedCommits === 1 ? "" : "s"}`, + fixable: false, + }); + } + } + } catch { + // Non-fatal — worktree lifecycle check failed + } } // ── Runtime Health Checks ────────────────────────────────────────────────── @@ -692,6 +810,42 @@ export async function checkRuntimeHealth( // Non-fatal — metrics check failed } + // ── Metrics ledger bloat ────────────────────────────────────────────── + // The metrics ledger has no TTL and grows by one entry per completed unit. + // At 50 units/day a project can accumulate tens of thousands of entries over + // months of use. Prune to the newest 1500 when the threshold is exceeded. + try { + const metricsFilePath = join(root, "metrics.json"); + if (existsSync(metricsFilePath)) { + try { + const raw = readFileSync(metricsFilePath, "utf-8"); + const parsed = JSON.parse(raw); + const BLOAT_UNITS_THRESHOLD = 2000; + if (parsed.version === 1 && Array.isArray(parsed.units) && parsed.units.length > BLOAT_UNITS_THRESHOLD) { + const fileSizeMB = (statSync(metricsFilePath).size / (1024 * 1024)).toFixed(1); + issues.push({ + severity: "warning", + code: "metrics_ledger_bloat", + scope: "project", + unitId: "project", + message: `metrics.json has ${parsed.units.length} unit entries (${fileSizeMB}MB) — threshold is ${BLOAT_UNITS_THRESHOLD}. Run /gsd doctor --fix to prune to the newest 1500 entries.`, + file: ".gsd/metrics.json", + fixable: true, + }); + if (shouldFix("metrics_ledger_bloat")) { + const { pruneMetricsLedger } = await import("./metrics.js"); + const removed = pruneMetricsLedger(basePath, 1500); + fixesApplied.push(`pruned metrics ledger: removed ${removed} oldest entries (${parsed.units.length - removed} remain)`); + } + } + } catch { + // JSON parse failed — already handled by the integrity check above + } + } + } catch { + // Non-fatal — metrics bloat check failed + } + // ── Large planning file detection ────────────────────────────────────── // Files over 100KB can cause LLM context pressure. Report the worst offenders. try { @@ -732,6 +886,50 @@ export async function checkRuntimeHealth( } catch { // Non-fatal — large file scan failed } + + // ── Snapshot ref bloat ──────────────────────────────────────────────── + // refs/gsd/snapshots/ accumulate over time. Prune to newest 5 per label + // when total count exceeds threshold. + try { + if (nativeIsRepo(basePath)) { + const refs = nativeForEachRef(basePath, "refs/gsd/snapshots/"); + if (refs.length > 50) { + issues.push({ + severity: "warning", + code: "snapshot_ref_bloat", + scope: "project", + unitId: "project", + message: `${refs.length} snapshot refs found under refs/gsd/snapshots/ — pruning to newest 5 per label will reclaim git storage`, + fixable: true, + }); + + if (shouldFix("snapshot_ref_bloat")) { + const byLabel = new Map(); + for (const ref of refs) { + const parts = ref.split("/"); + const label = parts.slice(0, -1).join("/"); + if (!byLabel.has(label)) byLabel.set(label, []); + byLabel.get(label)!.push(ref); + } + let pruned = 0; + for (const [, labelRefs] of byLabel) { + const sorted = labelRefs.sort(); + for (const old of sorted.slice(0, -5)) { + try { + nativeUpdateRef(basePath, old); + pruned++; + } catch { /* skip */ } + } + } + if (pruned > 0) { + fixesApplied.push(`pruned ${pruned} old snapshot ref(s)`); + } + } + } + } + } catch { + // Non-fatal — snapshot ref check failed + } } /** @@ -786,3 +984,85 @@ function buildStateMarkdownForCheck(state: Awaited boolean, +): Promise { + try { + const projectsDir = externalProjectsRoot(); + + if (!existsSync(projectsDir)) return; + + let entries: string[]; + try { + entries = readdirSync(projectsDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { + return; // Can't read directory — skip + } + + if (entries.length === 0) return; + + const orphaned: Array<{ hash: string; gitRoot: string; remoteUrl: string }> = []; + let unknownCount = 0; + + for (const hash of entries) { + const dirPath = join(projectsDir, hash); + const meta = readRepoMeta(dirPath); + if (!meta) { + unknownCount++; + continue; + } + if (!existsSync(meta.gitRoot)) { + orphaned.push({ hash, gitRoot: meta.gitRoot, remoteUrl: meta.remoteUrl }); + } + } + + if (orphaned.length === 0) return; + + const labels = orphaned.slice(0, 3).map(o => o.gitRoot).join(", "); + const overflow = orphaned.length > 3 ? ` (+${orphaned.length - 3} more)` : ""; + const unknownNote = unknownCount > 0 ? ` — ${unknownCount} additional director${unknownCount === 1 ? "y" : "ies"} have no metadata yet (open those repos once to register them)` : ""; + + issues.push({ + severity: "info", + code: "orphaned_project_state", + scope: "project", + unitId: "global", + message: `${orphaned.length} orphaned GSD project state director${orphaned.length === 1 ? "y" : "ies"} in ${projectsDir} whose git root no longer exists: ${labels}${overflow}${unknownNote}. Run /gsd cleanup projects to audit or /gsd cleanup projects --fix to reclaim disk space.`, + file: projectsDir, + fixable: true, + }); + + if (shouldFix("orphaned_project_state")) { + let removed = 0; + for (const { hash } of orphaned) { + try { + rmSync(join(projectsDir, hash), { recursive: true, force: true }); + removed++; + } catch { + // Individual removal failure is non-fatal — continue with remaining + } + } + fixesApplied.push(`removed ${removed} orphaned project state director${removed === 1 ? "y" : "ies"} from ${projectsDir}`); + } + } catch { + // Non-fatal — global health check must not block per-project doctor + } +} diff --git a/src/resources/extensions/gsd/doctor-proactive.ts b/src/resources/extensions/gsd/doctor-proactive.ts index 2e30e090a..0eb3b016f 100644 --- a/src/resources/extensions/gsd/doctor-proactive.ts +++ b/src/resources/extensions/gsd/doctor-proactive.ts @@ -21,8 +21,10 @@ import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.j import { abortAndReset } from "./git-self-heal.js"; import { rebuildState } from "./doctor.js"; import { deriveState } from "./state.js"; -import { readIntegrationBranch } from "./git-service.js"; -import { nativeBranchExists, nativeIsRepo } from "./native-git-bridge.js"; +import { resolveMilestoneIntegrationBranch } from "./git-service.js"; +import { nativeIsRepo } from "./native-git-bridge.js"; +import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { runEnvironmentChecks } from "./doctor-environment.js"; // ── Health Score Tracking ────────────────────────────────────────────────── @@ -276,11 +278,15 @@ export async function preDispatchHealthGate(basePath: string): Promise r.name === "disk_space" && r.status === "error"); + if (diskError) { + issues.push(`${diskError.message}${diskError.detail ? ` — ${diskError.detail}` : ""}`); + } + } catch { + // Non-fatal — dispatch continues if env check fails + } + // If we had critical issues that couldn't be auto-healed, block dispatch if (issues.length > 0) { return { diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index 028b3e72c..29bce4f7b 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -57,11 +57,21 @@ export type DoctorIssueCode = // GSD state structural checks | "circular_slice_dependency" | "orphaned_slice_directory" + | "missing_slice_dir" | "duplicate_task_id" | "task_file_not_in_plan" | "stale_replan_file" | "future_timestamp" + // Worktree lifecycle checks + | "worktree_branch_merged" + | "worktree_stale" + | "worktree_dirty" + | "worktree_unpushed" + // Snapshot ref bloat + | "snapshot_ref_bloat" // Runtime data integrity + | "orphaned_project_state" + | "metrics_ledger_bloat" | "metrics_ledger_corrupt" | "large_planning_file" // Slow environment checks (opt-in via --build / --test flags) @@ -74,11 +84,30 @@ export type DoctorIssueCode = * they are resolved by the complete-slice/complete-milestone dispatch units. * Consumers (e.g. auto-post-unit health tracking) should exclude these from * error counts when running at task fixLevel to avoid false escalation. + * + * Only the slice summary is deferred here because it requires LLM-generated + * content. Roadmap checkbox and UAT stub are mechanical bookkeeping and are + * fixed immediately to avoid inconsistent state if the session stops before + * complete-slice runs (#1808). */ export const COMPLETION_TRANSITION_CODES = new Set([ "all_tasks_done_missing_slice_summary", - "all_tasks_done_missing_slice_uat", - "all_tasks_done_roadmap_not_checked", +]); + +/** + * Issue codes that represent global or completion-critical state. + * These must NOT be auto-fixed when fixLevel is "task" — automated + * post-task health checks must never delete external project state directories + * or remove completed-unit keys (which causes state reversion / data loss). + * + * orphaned_completed_units: Removing completed-unit keys causes deriveState to + * consider those tasks incomplete, reverting the user to an earlier slice and + * effectively discarding all work past that point (#1809). This must only be + * fixed by an explicit manual doctor run (fixLevel="all"). + */ +export const GLOBAL_STATE_CODES = new Set([ + "orphaned_project_state", + "orphaned_completed_units", ]); export interface DoctorIssue { diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 9af4f063b..5e74e0a42 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -8,9 +8,9 @@ import { invalidateAllCaches } from "./cache.js"; import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js"; import type { DoctorIssue, DoctorIssueCode, DoctorReport } from "./doctor-types.js"; -import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js"; +import { COMPLETION_TRANSITION_CODES, GLOBAL_STATE_CODES } from "./doctor-types.js"; import type { RoadmapSliceEntry } from "./types.js"; -import { checkGitHealth, checkRuntimeHealth } from "./doctor-checks.js"; +import { checkGitHealth, checkRuntimeHealth, checkGlobalHealth } from "./doctor-checks.js"; import { checkEnvironmentHealth } from "./doctor-environment.js"; import { runProviderChecks } from "./doctor-providers.js"; @@ -265,6 +265,21 @@ async function markTaskDoneInPlan(basePath: string, milestoneId: string, sliceId } } +async function markTaskUndoneInPlan(basePath: string, milestoneId: string, sliceId: string, taskId: string, fixesApplied: string[]): Promise { + const planPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN"); + if (!planPath) return; + const content = await loadFile(planPath); + if (!content) return; + const updated = content.replace( + new RegExp(`^(\\s*-\\s+)\\[x\\]\\s+\\*\\*${taskId}:`, "mi"), + `$1[ ] **${taskId}:`, + ); + if (updated !== content) { + await saveFile(planPath, updated); + fixesApplied.push(`unchecked ${taskId} in ${planPath} (missing summary — task will re-execute)`); + } +} + async function markSliceDoneInRoadmap(basePath: string, milestoneId: string, sliceId: string, fixesApplied: string[]): Promise { const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); if (!roadmapPath) return; @@ -476,6 +491,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; const shouldFix = (code: DoctorIssueCode): boolean => { if (!fix || dryRun) return false; if (fixLevel === "task" && COMPLETION_TRANSITION_CODES.has(code)) return false; + if (fixLevel === "task" && GLOBAL_STATE_CODES.has(code)) return false; return true; }; @@ -515,6 +531,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix); const runtimeMs = Date.now() - t0runtime; + // Global health checks — cross-project state (e.g. orphaned project state dirs) + await checkGlobalHealth(issues, fixesApplied, shouldFix); + // Environment health checks — timed const t0env = Date.now(); await checkEnvironmentHealth(basePath, issues, { @@ -578,15 +597,33 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; // Validate milestone title for delimiter characters that break state documents. const milestoneTitleIssue = validateTitle(milestone.title); if (milestoneTitleIssue) { - issues.push({ - severity: "warning", - code: "delimiter_in_title", - scope: "milestone", - unitId: milestoneId, - message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: false, - }); + const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + let wasFixed = false; + if (shouldFix("delimiter_in_title") && roadmapFile) { + try { + const raw = readFileSync(roadmapFile, "utf-8"); + // Replace em/en dashes with " - " in the H1 title line only + const sanitized = raw.replace(/^(# .*)$/m, (line) => + line.replace(/[\u2014\u2013]/g, "-"), + ); + if (sanitized !== raw) { + await saveFile(roadmapFile, sanitized); + fixesApplied.push(`sanitized delimiter characters in ${milestoneId} title`); + wasFixed = true; + } + } catch { /* non-fatal — report the warning below */ } + } + if (!wasFixed) { + issues.push({ + severity: "warning", + code: "delimiter_in_title", + scope: "milestone", + unitId: milestoneId, + message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`, + file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), + fixable: true, + }); + } } const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); @@ -638,6 +675,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; // Validate slice title for delimiter characters. const sliceTitleIssue = validateTitle(slice.title); if (sliceTitleIssue) { + // Slice titles live inside the roadmap H1/checkbox lines — the milestone-level + // fix above already sanitizes the roadmap file. For slices we only report, because + // the title comes from the checkbox text and requires careful regex to fix safely. issues.push({ severity: "warning", code: "delimiter_in_title", @@ -666,7 +706,26 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; } const slicePath = resolveSlicePath(basePath, milestoneId, slice.id); - if (!slicePath) continue; + if (!slicePath) { + const expectedPath = relSlicePath(basePath, milestoneId, slice.id); + issues.push({ + severity: slice.done ? "warning" : "error", + code: "missing_slice_dir", + scope: "slice", + unitId, + message: slice.done + ? `Missing slice directory for ${unitId} (slice is complete — cosmetic only)` + : `Missing slice directory for ${unitId}`, + file: expectedPath, + fixable: true, + }); + if (fix) { + const absoluteSliceDir = join(milestonePath, "slices", slice.id); + mkdirSync(absoluteSliceDir, { recursive: true }); + fixesApplied.push(`created ${absoluteSliceDir}`); + } + continue; + } const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id); if (!tasksDir) { @@ -733,6 +792,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; } catch { /* non-fatal */ } let allTasksDone = plan.tasks.length > 0; + let taskUncheckedByDoctor = false; for (const task of plan.tasks) { const taskUnitId = `${unitId}/${task.id}`; const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"); @@ -744,30 +804,14 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; code: "task_done_missing_summary", scope: "task", unitId: taskUnitId, - message: `Task ${task.id} is marked done but summary is missing`, - file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), + message: `Task ${task.id} is marked done but summary is missing — unchecking so it re-executes`, + file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), fixable: true, }); - dryRunCanFix("task_done_missing_summary", `create stub summary for ${taskUnitId}`); + dryRunCanFix("task_done_missing_summary", `uncheck ${task.id} in plan for ${taskUnitId}`); if (shouldFix("task_done_missing_summary")) { - const stubPath = join( - basePath, ".gsd", "milestones", milestoneId, "slices", slice.id, "tasks", - `${task.id}-SUMMARY.md`, - ); - const stubContent = [ - `---`, - `status: done`, - `result: unknown`, - `doctor_generated: true`, - `---`, - ``, - `# ${task.id}: ${task.title || "Unknown"}`, - ``, - `Summary stub generated by \`/gsd doctor\` \u2014 task was marked done but no summary existed.`, - ``, - ].join("\n"); - await saveFile(stubPath, stubContent); - fixesApplied.push(`created stub summary for ${taskUnitId}`); + await markTaskUndoneInPlan(basePath, milestoneId, slice.id, task.id, fixesApplied); + taskUncheckedByDoctor = true; } } @@ -831,6 +875,15 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; allTasksDone = allTasksDone && task.done; } + // ── #1850: cascade slice uncheck when task_done_missing_summary fires ── + // When doctor unchecks tasks inside a done slice, the slice must also be + // unchecked so the state machine re-enters the executing phase. Without + // this, state.ts skips done slices and the unchecked tasks never run, + // causing doctor to fire again on every start (infinite loop). + if (taskUncheckedByDoctor && slice.done) { + await markSliceUndoneInRoadmap(basePath, milestoneId, slice.id, fixesApplied); + } + // Blocker-without-replan detection const replanPath = resolveSliceFile(basePath, milestoneId, slice.id, "REPLAN"); if (!replanPath) { diff --git a/src/resources/extensions/gsd/exit-command.ts b/src/resources/extensions/gsd/exit-command.ts index 6812f0d58..f4ff48b05 100644 --- a/src/resources/extensions/gsd/exit-command.ts +++ b/src/resources/extensions/gsd/exit-command.ts @@ -10,8 +10,20 @@ export function registerExitCommand( description: "Exit GSD gracefully", handler: async (_args: string, ctx: ExtensionCommandContext) => { // Stop auto-mode first so locks and activity state are cleaned up before shutdown. - const stopAuto = deps.stopAuto ?? (await importExtensionModule(import.meta.url, "./auto.js")).stopAuto; - await stopAuto(ctx, pi, "Graceful exit"); + // Wrapped in try/catch: if gsd-pi was updated on disk mid-session, the dynamic + // import may resolve a new auto-worktree.js whose static imports reference + // exports absent from the process-cached native-git-bridge.js (ESM cache is + // immutable). The user's work is already saved — this is cleanup only. + try { + const stopAuto = deps.stopAuto ?? (await importExtensionModule(import.meta.url, "./auto.js")).stopAuto; + await stopAuto(ctx, pi, "Graceful exit"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + ctx.ui?.notify?.( + `Auto-mode cleanup skipped (module version mismatch): ${msg}`, + "warning", + ); + } ctx.shutdown(); }, }); diff --git a/src/resources/extensions/gsd/export.ts b/src/resources/extensions/gsd/export.ts index 009f63659..bfac9cb25 100644 --- a/src/resources/extensions/gsd/export.ts +++ b/src/resources/extensions/gsd/export.ts @@ -4,7 +4,7 @@ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { writeFileSync, mkdirSync } from "node:fs"; import { join, basename } from "node:path"; -import { exec } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import { getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk, @@ -20,20 +20,13 @@ import { getErrorMessage } from "./error-utils.js"; * Non-blocking, non-fatal — failures are silently ignored. */ export function openInBrowser(filePath: string): void { - const cmd = - process.platform === "darwin" ? "open" : - process.platform === "win32" ? "start" : - "xdg-open"; - - // On Windows, `start` needs an empty title argument when the path has spaces - const args = process.platform === "win32" - ? `"" "${filePath}"` - : `"${filePath}"`; - - exec(`${cmd} ${args}`, (err) => { - // Non-fatal — if the browser can't be opened, the file path is still shown - if (err) void err; - }); + if (process.platform === "win32") { + // PowerShell's Start-Process handles paths with '&' and spaces safely. + execFile("powershell", ["-c", `Start-Process '${filePath.replace(/'/g, "''")}'`], () => {}); + } else { + const cmd = process.platform === "darwin" ? "open" : "xdg-open"; + execFile(cmd, [filePath], () => {}); + } } /** diff --git a/src/resources/extensions/gsd/extension-manifest.json b/src/resources/extensions/gsd/extension-manifest.json index efeb7bfbe..a1b2877be 100644 --- a/src/resources/extensions/gsd/extension-manifest.json +++ b/src/resources/extensions/gsd/extension-manifest.json @@ -8,8 +8,8 @@ "provides": { "tools": [ "bash", "write", "read", "edit", - "gsd_save_decision", "gsd_save_summary", - "gsd_update_requirement", "gsd_generate_milestone_id" + "gsd_decision_save", "gsd_summary_save", + "gsd_requirement_update", "gsd_milestone_generate_id" ], "commands": ["gsd", "kill", "worktree", "exit"], "hooks": ["session_start"], diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index f60c697a5..c5d7fada0 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -374,20 +374,37 @@ function _parsePlanImpl(content: string): SlicePlan { for (const line of taskLines) { const cbMatch = line.match(/^-\s+\[([ xX])\]\s+\*\*([\w.]+):\s+(.+?)\*\*\s*(.*)/); - if (cbMatch) { + // Heading-style: ### T01 -- Title, ### T01: Title, ### T01 — Title + const hdMatch = !cbMatch ? line.match(/^#{2,4}\s+([\w.]+)\s*(?:--|—|:)\s*(.+)/) : null; + if (cbMatch || hdMatch) { if (currentTask) tasks.push(currentTask); - const rest = cbMatch[4] || ''; - const estMatch = rest.match(/`est:([^`]+)`/); - const estimate = estMatch ? estMatch[1] : ''; + if (cbMatch) { + const rest = cbMatch[4] || ''; + const estMatch = rest.match(/`est:([^`]+)`/); + const estimate = estMatch ? estMatch[1] : ''; - currentTask = { - id: cbMatch[2], - title: cbMatch[3], - description: '', - done: cbMatch[1].toLowerCase() === 'x', - estimate, - }; + currentTask = { + id: cbMatch[2], + title: cbMatch[3], + description: '', + done: cbMatch[1].toLowerCase() === 'x', + estimate, + }; + } else { + const rest = hdMatch![2] || ''; + const titleEstMatch = rest.match(/^(.+?)\s*`est:([^`]+)`\s*$/); + const title = titleEstMatch ? titleEstMatch[1].trim() : rest.trim(); + const estimate = titleEstMatch ? titleEstMatch[2] : ''; + + currentTask = { + id: hdMatch![1], + title, + description: '', + done: false, + estimate, + }; + } } else if (currentTask && line.match(/^\s*-\s+Files:\s*(.*)/)) { const filesMatch = line.match(/^\s*-\s+Files:\s*(.*)/); if (filesMatch) { diff --git a/src/resources/extensions/gsd/forensics.ts b/src/resources/extensions/gsd/forensics.ts index 2dcda6549..62c89279d 100644 --- a/src/resources/extensions/gsd/forensics.ts +++ b/src/resources/extensions/gsd/forensics.ts @@ -12,6 +12,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { join, dirname, relative } from "node:path"; import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; import { extractTrace, type ExecutionTrace } from "./session-forensics.js"; import { nativeParseJsonlTail } from "./native-parser-bridge.js"; @@ -102,9 +103,14 @@ export async function handleForensics( const report = await buildForensicReport(basePath); const savedPath = saveForensicReport(basePath, report, problemDescription); - // Derive GSD source dir for prompt - const __extensionDir = dirname(fileURLToPath(import.meta.url)); - const gsdSourceDir = __extensionDir; + // Derive GSD source dir for prompt — fall back to ~/.gsd/agent/extensions/gsd/ + // when import.meta.url resolves to the npm-global install path (Windows). + let gsdSourceDir = dirname(fileURLToPath(import.meta.url)); + if (!existsSync(join(gsdSourceDir, "prompts"))) { + const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + const fallback = join(gsdHome, "agent", "extensions", "gsd"); + if (existsSync(join(fallback, "prompts"))) gsdSourceDir = fallback; + } const forensicData = formatReportForPrompt(report); const content = loadPrompt("forensics", { @@ -123,7 +129,7 @@ export async function handleForensics( // ─── Report Builder ─────────────────────────────────────────────────────────── -async function buildForensicReport(basePath: string): Promise { +export async function buildForensicReport(basePath: string): Promise { const anomalies: ForensicAnomaly[] = []; // 1. Derive current state diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 7a7c25fbe..00b4f717f 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -15,6 +15,7 @@ import { gsdRoot } from "./paths.js"; import { GIT_NO_PROMPT_ENV } from "./git-constants.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; + import { detectWorktreeName, SLICE_BRANCH_RE, @@ -276,6 +277,91 @@ export function writeIntegrationBranch( // .gsd/ is managed externally (symlinked) — metadata is not committed to git. } +export type IntegrationBranchResolutionStatus = "recorded" | "fallback" | "missing"; + +export interface IntegrationBranchResolution { + recordedBranch: string | null; + effectiveBranch: string | null; + status: IntegrationBranchResolutionStatus; + reason: string; +} + +/** + * Resolve a milestone's recorded integration branch into an actionable status. + * + * This helper is intentionally scoped to milestones that already have recorded + * metadata. If no integration branch is recorded, it returns `missing` with no + * effective branch so callers can continue with their existing non-milestone + * fallback logic (for example worktree/current-branch detection in getMainBranch). + */ +export function resolveMilestoneIntegrationBranch( + basePath: string, + milestoneId: string, + prefs: GitPreferences = {}, +): IntegrationBranchResolution { + const recordedBranch = readIntegrationBranch(basePath, milestoneId); + if (!recordedBranch) { + return { + recordedBranch: null, + effectiveBranch: null, + status: "missing", + reason: `Milestone ${milestoneId} has no recorded integration branch metadata.`, + }; + } + + if (nativeBranchExists(basePath, recordedBranch)) { + return { + recordedBranch, + effectiveBranch: recordedBranch, + status: "recorded", + reason: `Using recorded integration branch "${recordedBranch}" for milestone ${milestoneId}.`, + }; + } + + const configuredBranch = prefs.main_branch && VALID_BRANCH_NAME.test(prefs.main_branch) + ? prefs.main_branch + : null; + + if (configuredBranch) { + if (nativeBranchExists(basePath, configuredBranch)) { + return { + recordedBranch, + effectiveBranch: configuredBranch, + status: "fallback", + reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists; using configured git.main_branch "${configuredBranch}" instead.`, + }; + } + + return { + recordedBranch, + effectiveBranch: null, + status: "missing", + reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists, and configured git.main_branch "${configuredBranch}" is unavailable.`, + }; + } + + try { + const detectedBranch = nativeDetectMainBranch(basePath); + if (detectedBranch && VALID_BRANCH_NAME.test(detectedBranch) && nativeBranchExists(basePath, detectedBranch)) { + return { + recordedBranch, + effectiveBranch: detectedBranch, + status: "fallback", + reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists; using detected fallback branch "${detectedBranch}" instead.`, + }; + } + } catch { + // Fall through to the explicit missing result below. + } + + return { + recordedBranch, + effectiveBranch: null, + status: "missing", + reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists, and no safe fallback branch could be determined.`, + }; +} + // ─── Git Helper ──────────────────────────────────────────────────────────── @@ -480,10 +566,9 @@ export class GitServiceImpl { // Check milestone integration branch — recorded when auto-mode starts if (this._milestoneId) { - const integrationBranch = readIntegrationBranch(this.basePath, this._milestoneId); - if (integrationBranch) { - // Verify the branch still exists locally (could have been deleted) - if (nativeBranchExists(this.basePath, integrationBranch)) return integrationBranch; + const resolved = resolveMilestoneIntegrationBranch(this.basePath, this._milestoneId); + if (resolved.effectiveBranch) { + return resolved.effectiveBranch; } } @@ -598,10 +683,11 @@ export function createDraftPR( body: string, ): string | null { try { - const result = execSync( - `gh pr create --draft --title ${JSON.stringify(title)} --body ${JSON.stringify(body)}`, - { cwd: basePath, encoding: "utf8", timeout: 30000, env: GIT_NO_PROMPT_ENV }, - ); + const result = execFileSync("gh", [ + "pr", "create", "--draft", + "--title", title, + "--body", body, + ], { cwd: basePath, encoding: "utf8", timeout: 30000, env: GIT_NO_PROMPT_ENV }); return result.trim(); } catch { return null; diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index a31a2329e..bcd8c52b3 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -168,7 +168,7 @@ function openRawDb(path: string): unknown { // ─── Schema ──────────────────────────────────────────────────────────────── -const SCHEMA_VERSION = 3; +const SCHEMA_VERSION = 4; function initSchema(db: DbAdapter, fileBacked: boolean): void { // WAL mode for file-backed databases (must be outside transaction) @@ -195,6 +195,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { choice TEXT NOT NULL DEFAULT '', rationale TEXT NOT NULL DEFAULT '', revisable TEXT NOT NULL DEFAULT '', + made_by TEXT NOT NULL DEFAULT 'agent', superseded_by TEXT DEFAULT NULL ) `); @@ -360,6 +361,22 @@ function migrateSchema(db: DbAdapter): void { ).run({ ":version": 3, ":applied_at": new Date().toISOString() }); } + // v3 → v4: add made_by column to decisions table + if (currentVersion < 4) { + // Add made_by column — default 'agent' for existing rows (pre-attribution decisions) + db.exec(`ALTER TABLE decisions ADD COLUMN made_by TEXT NOT NULL DEFAULT 'agent'`); + + // Recreate views to pick up new columns (SQLite expands SELECT * at view creation time) + db.exec("DROP VIEW IF EXISTS active_decisions"); + db.exec( + "CREATE VIEW active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL", + ); + + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ ":version": 4, ":applied_at": new Date().toISOString() }); + } + db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -471,8 +488,8 @@ export function insertDecision(d: Omit): void { throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); currentDb .prepare( - `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) - VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`, + `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by) + VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)`, ) .run({ ":id": d.id, @@ -482,6 +499,7 @@ export function insertDecision(d: Omit): void { ":choice": d.choice, ":rationale": d.rationale, ":revisable": d.revisable, + ":made_by": d.made_by ?? "agent", ":superseded_by": d.superseded_by, }); } @@ -502,6 +520,7 @@ export function getDecisionById(id: string): Decision | null { choice: row["choice"] as string, rationale: row["rationale"] as string, revisable: row["revisable"] as string, + made_by: (row["made_by"] as string as import("./types.js").DecisionMadeBy) ?? "agent", superseded_by: (row["superseded_by"] as string) ?? null, }; } @@ -521,6 +540,7 @@ export function getActiveDecisions(): Decision[] { choice: row["choice"] as string, rationale: row["rationale"] as string, revisable: row["revisable"] as string, + made_by: (row["made_by"] as string as import("./types.js").DecisionMadeBy) ?? "agent", superseded_by: null, })); } @@ -644,8 +664,8 @@ export function upsertDecision(d: Omit): void { throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); currentDb .prepare( - `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by) - VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`, + `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by) + VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)`, ) .run({ ":id": d.id, @@ -655,6 +675,7 @@ export function upsertDecision(d: Omit): void { ":choice": d.choice, ":rationale": d.rationale, ":revisable": d.revisable, + ":made_by": d.made_by ?? "agent", ":superseded_by": d.superseded_by ?? null, }); } @@ -783,9 +804,15 @@ export function reconcileWorktreeDb( try { adapter.exec(`ATTACH DATABASE '${worktreeDbPath}' AS wt`); try { + // Check if attached wt database has the made_by column (legacy v3 worktrees won't) + const wtInfo = adapter.prepare("PRAGMA wt.table_info('decisions')").all(); + const hasMadeBy = wtInfo.some((col) => col["name"] === "made_by"); + const decConf = adapter .prepare( - `SELECT m.id FROM decisions m INNER JOIN wt.decisions w ON m.id = w.id WHERE m.decision != w.decision OR m.choice != w.choice OR m.rationale != w.rationale OR m.superseded_by IS NOT w.superseded_by`, + `SELECT m.id FROM decisions m INNER JOIN wt.decisions w ON m.id = w.id WHERE m.decision != w.decision OR m.choice != w.choice OR m.rationale != w.rationale OR ${ + hasMadeBy ? "m.made_by != w.made_by" : "'agent' != 'agent'" + } OR m.superseded_by IS NOT w.superseded_by`, ) .all(); for (const row of decConf) @@ -808,10 +835,12 @@ export function reconcileWorktreeDb( .prepare( ` INSERT OR REPLACE INTO decisions ( - id, when_context, scope, decision, choice, rationale, revisable, superseded_by + id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by ) SELECT - id, when_context, scope, decision, choice, rationale, revisable, superseded_by + id, when_context, scope, decision, choice, rationale, revisable, ${ + hasMadeBy ? "made_by" : "'agent'" + }, superseded_by FROM wt.decisions `, ) diff --git a/src/resources/extensions/gsd/guided-flow-queue.ts b/src/resources/extensions/gsd/guided-flow-queue.ts index 619690a83..5b0b21e94 100644 --- a/src/resources/extensions/gsd/guided-flow-queue.ts +++ b/src/resources/extensions/gsd/guided-flow-queue.ts @@ -7,7 +7,7 @@ */ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; -import { showNextAction } from "../shared/mod.js"; +import { showNextAction } from "../shared/tui.js"; import { setQueuePhaseActive } from "./index.js"; import { loadFile } from "./files.js"; import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; @@ -170,7 +170,7 @@ export async function showQueueAdd( const existingContext = await buildExistingMilestonesContext(basePath, milestoneIds, state); // ── Determine next milestone ID ───────────────────────────────────── - // Note: the LLM will use the gsd_generate_milestone_id tool to get IDs + // Note: the LLM will use the gsd_milestone_generate_id tool to get IDs // at creation time, but we still mention the next ID in the preamble // for context about where the sequence is. const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 6f1b378f5..35fb43e64 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -7,7 +7,7 @@ */ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent"; -import { showNextAction } from "../shared/mod.js"; +import { showNextAction } from "../shared/tui.js"; import { loadFile, parseRoadmap } from "./files.js"; import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; import { buildSkillActivationBlock } from "./auto-prompts.js"; @@ -31,14 +31,15 @@ import { join } from "node:path"; import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs"; import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js"; import { nativeIsRepo, nativeInit } from "./native-git-bridge.js"; +import { isInheritedRepo } from "./repo-identity.js"; import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { detectProjectState } from "./detection.js"; import { showProjectInit, offerMigration } from "./init-wizard.js"; import { validateDirectory } from "./validate-directory.js"; -import { showConfirm } from "../shared/mod.js"; +import { showConfirm } from "../shared/tui.js"; import { debugLog } from "./debug-logger.js"; -import { findMilestoneIds, nextMilestoneId } from "./milestone-ids.js"; +import { findMilestoneIds, nextMilestoneId, reserveMilestoneId, getReservedMilestoneIds } from "./milestone-ids.js"; import { parkMilestone, discardMilestone } from "./milestone-actions.js"; import { resolveModelWithFallbacksForUnit } from "./preferences-models.js"; @@ -47,6 +48,7 @@ export { MILESTONE_ID_RE, generateMilestoneSuffix, nextMilestoneId, extractMilestoneSeq, parseMilestoneId, milestoneIdSort, maxMilestoneNum, findMilestoneIds, + reserveMilestoneId, claimReservedId, getReservedMilestoneIds, clearReservedMilestoneIds, } from "./milestone-ids.js"; export { showQueue, handleQueueReorder, showQueueAdd, @@ -54,6 +56,20 @@ export { } from "./guided-flow-queue.js"; import { getErrorMessage } from "./error-utils.js"; +// ─── ID Generation with Reservation ───────────────────────────────────────── + +/** + * Generate the next milestone ID, accounting for reserved IDs, and reserve it. + * Ensures any preview ID shown in the UI matches what `gsd_milestone_generate_id` + * will later return. + */ +function nextMilestoneIdReserved(existingIds: string[], uniqueEnabled: boolean): string { + const allIds = [...new Set([...existingIds, ...getReservedMilestoneIds()])]; + const id = nextMilestoneId(allIds, uniqueEnabled); + reserveMilestoneId(id); + return id; +} + // ─── Commit Instruction Helpers ────────────────────────────────────────────── /** Build commit instruction for planning prompts. .gsd/ is managed externally and always gitignored. */ @@ -336,7 +352,7 @@ function buildHeadlessDiscussPrompt(nextId: string, seedContext: string, _basePa * Ensures git repo, .gsd/ structure, gitignore, and preferences all exist. */ function bootstrapGsdProject(basePath: string): void { - if (!nativeIsRepo(basePath)) { + if (!nativeIsRepo(basePath) || isInheritedRepo(basePath)) { const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; nativeInit(basePath, mainBranch); } @@ -367,7 +383,7 @@ export async function showHeadlessMilestoneCreation( // Generate next milestone ID const existingIds = findMilestoneIds(basePath); const prefs = loadEffectiveGSDPreferences(); - const nextId = nextMilestoneId(existingIds, prefs?.preferences?.unique_milestone_ids ?? false); + const nextId = nextMilestoneIdReserved(existingIds, prefs?.preferences?.unique_milestone_ids ?? false); // Create milestone directory const milestoneDir = join(gsdRoot(basePath), "milestones", nextId, "slices"); @@ -557,7 +573,7 @@ export async function showDiscuss( } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); + const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false }; await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone"); } @@ -798,7 +814,7 @@ async function handleMilestoneActions( if (choice === "skip") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); + const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, @@ -860,7 +876,10 @@ export async function showSmartEntry( } // ── Ensure git repo exists — GSD needs it for worktree isolation ────── - if (!nativeIsRepo(basePath)) { + // Also handle inherited repos: if basePath is a subdirectory of another + // git repo that has no .gsd, create a fresh repo to prevent cross-project + // state leaks (#1639). + if (!nativeIsRepo(basePath) || isInheritedRepo(basePath)) { const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; nativeInit(basePath, mainBranch); } @@ -946,7 +965,7 @@ export async function showSmartEntry( } const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); + const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); const isFirst = milestoneIds.length === 0; if (isFirst) { @@ -1009,7 +1028,7 @@ export async function showSmartEntry( if (choice === "new_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); + const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; await dispatchWorkflow(pi, buildDiscussPrompt(nextId, @@ -1075,7 +1094,7 @@ export async function showSmartEntry( } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); + const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, @@ -1159,7 +1178,7 @@ export async function showSmartEntry( } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); + const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, diff --git a/src/resources/extensions/gsd/init-wizard.ts b/src/resources/extensions/gsd/init-wizard.ts index 555139b81..c83cda4a6 100644 --- a/src/resources/extensions/gsd/init-wizard.ts +++ b/src/resources/extensions/gsd/init-wizard.ts @@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { showNextAction } from "../shared/mod.js"; +import { showNextAction } from "../shared/tui.js"; import { nativeIsRepo, nativeInit } from "./native-git-bridge.js"; import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; import { gsdRoot } from "./paths.js"; diff --git a/src/resources/extensions/gsd/journal.ts b/src/resources/extensions/gsd/journal.ts new file mode 100644 index 000000000..9b1fa9487 --- /dev/null +++ b/src/resources/extensions/gsd/journal.ts @@ -0,0 +1,134 @@ +/** + * GSD Event Journal — structured JSONL event log for auto-mode iterations. + * + * Writes daily-rotated JSONL files to `.gsd/journal/YYYY-MM-DD.jsonl`. + * Zero imports from `auto/` — depends only on node:fs, node:path, and paths.ts. + * + * Observability: + * - Each line in the JSONL file is a self-contained JournalEntry + * - Events are grouped by flowId (one per iteration) with monotonic seq numbers + * - causedBy references enable causal chain reconstruction + * - queryJournal() enables programmatic filtering by flowId, eventType, unitId, time range + * - Silent failure: journal writes never throw — absence of events is the failure signal + */ + +import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { gsdRoot } from "./paths.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Event types emitted by the auto-mode loop and phases. */ +export type JournalEventType = + | "iteration-start" + | "dispatch-match" + | "dispatch-stop" + | "pre-dispatch-hook" + | "unit-start" + | "unit-end" + | "post-unit-hook" + | "terminal" + | "guard-block" + | "milestone-transition" + | "stuck-detected" + | "sidecar-dequeue" + | "iteration-end"; + +/** A single structured event in the journal. */ +export interface JournalEntry { + /** ISO-8601 timestamp */ + ts: string; + /** UUID grouping all events from one iteration */ + flowId: string; + /** Monotonically increasing sequence number within a flow */ + seq: number; + /** The kind of event */ + eventType: JournalEventType; + /** Name of the matched rule (from the unified registry), if applicable */ + rule?: string; + /** Causal reference to a prior event in this or another flow */ + causedBy?: { flowId: string; seq: number }; + /** Arbitrary structured payload (e.g. unitId, status, action details) */ + data?: Record; +} + +/** Filters for querying journal entries. */ +export interface JournalQueryFilters { + flowId?: string; + eventType?: string; + unitId?: string; + /** Filter by the rule name that produced the event */ + rule?: string; + /** ISO-8601 lower bound (inclusive) */ + after?: string; + /** ISO-8601 upper bound (inclusive) */ + before?: string; +} + +// ─── Emit ───────────────────────────────────────────────────────────────────── + +/** + * Append a journal event to the daily JSONL file. + * + * File path: `/journal/.jsonl` + * where the date is extracted from `entry.ts.slice(0, 10)`. + * + * Never throws — all errors are silently caught. + */ +export function emitJournalEvent(basePath: string, entry: JournalEntry): void { + try { + const journalDir = join(gsdRoot(basePath), "journal"); + mkdirSync(journalDir, { recursive: true }); + const dateStr = entry.ts.slice(0, 10); + const filePath = join(journalDir, `${dateStr}.jsonl`); + appendFileSync(filePath, JSON.stringify(entry) + "\n"); + } catch { + // Silent failure — journal must never break auto-mode + } +} + +// ─── Query ──────────────────────────────────────────────────────────────────── + +/** + * Read and filter journal entries from all daily JSONL files. + * + * Returns an empty array on any error (missing directory, corrupt files, etc.). + */ +export function queryJournal( + basePath: string, + filters?: JournalQueryFilters, +): JournalEntry[] { + try { + const journalDir = join(gsdRoot(basePath), "journal"); + const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort(); + + const entries: JournalEntry[] = []; + for (const file of files) { + const raw = readFileSync(join(journalDir, file), "utf-8"); + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as JournalEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + } + } + + if (!filters) return entries; + + return entries.filter(e => { + if (filters.flowId && e.flowId !== filters.flowId) return false; + if (filters.eventType && e.eventType !== filters.eventType) return false; + if (filters.rule && e.rule !== filters.rule) return false; + if (filters.unitId && (e.data as Record | undefined)?.unitId !== filters.unitId) return false; + if (filters.after && e.ts < filters.after) return false; + if (filters.before && e.ts > filters.before) return false; + return true; + }); + } catch { + // Missing directory, permission errors, etc. — return empty + return []; + } +} diff --git a/src/resources/extensions/gsd/json-persistence.ts b/src/resources/extensions/gsd/json-persistence.ts index c58c28cf1..8c6c2776c 100644 --- a/src/resources/extensions/gsd/json-persistence.ts +++ b/src/resources/extensions/gsd/json-persistence.ts @@ -39,13 +39,21 @@ export function loadJsonFileOrNull( } /** - * Save a JSON file, creating parent directories as needed. + * Save a JSON file atomically (write to .tmp, then rename). + * Creates parent directories as needed. * Non-fatal — swallows errors to prevent persistence from breaking operations. + * + * Uses atomic write-tmp-rename to prevent partial/corrupt files on crash. + * This is the canonical way to persist JSON state in GSD — all callers + * (queue-order, metrics, routing-history, reactive-graph) benefit from + * crash-safety without code changes. */ export function saveJsonFile(filePath: string, data: T): void { try { mkdirSync(dirname(filePath), { recursive: true }); - writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8"); + const tmp = filePath + ".tmp"; + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8"); + renameSync(tmp, filePath); } catch { // Non-fatal — don't let persistence failures break operation } diff --git a/src/resources/extensions/gsd/md-importer.ts b/src/resources/extensions/gsd/md-importer.ts index 29705a0c9..6a58e7e82 100644 --- a/src/resources/extensions/gsd/md-importer.ts +++ b/src/resources/extensions/gsd/md-importer.ts @@ -25,6 +25,8 @@ import { findMilestoneIds } from './guided-flow.js'; // ─── DECISIONS.md Parser ─────────────────────────────────────────────────── +const VALID_MADE_BY = new Set(['human', 'agent', 'collaborative']); + /** * Parse a DECISIONS.md markdown table into Decision objects (without seq). * Detects `(amends DXXX)` in the Decision column to build supersession info. @@ -64,6 +66,9 @@ export function parseDecisionsTable(content: string): Omit[] { const choice = cells[4].trim(); const rationale = cells[5].trim(); const revisable = cells[6].trim(); + // Made By column is optional for backward compatibility — defaults to 'agent' + const rawMadeBy = cells.length >= 8 ? cells[7].trim().toLowerCase() : 'agent'; + const made_by = (VALID_MADE_BY.has(rawMadeBy) ? rawMadeBy : 'agent') as import('./types.js').DecisionMadeBy; // Detect (amends DXXX) in the Decision column const amendsMatch = decisionText.match(/\(amends\s+(D\d+)\)/i); @@ -79,6 +84,7 @@ export function parseDecisionsTable(content: string): Omit[] { choice, rationale, revisable, + made_by, superseded_by: null, }); } diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index d3090ae44..ba86c7ab6 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -164,7 +164,7 @@ export function snapshotUnitMetrics( // Count tool calls in this message if (msg.content && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_call") toolCalls++; + if (block.type === "toolCall") toolCalls++; } } } else if (msg.role === "user") { @@ -205,7 +205,20 @@ export function snapshotUnitMetrics( unit.cacheHitRate = totalInput > 0 ? Math.round((tokens.cacheRead / totalInput) * 100) : 0; } - ledger.units.push(unit); + // ── Idempotency guard ────────────────────────────────────────────────── + // Prevent duplicate metrics entries when multiple callers snapshot the + // same unit (e.g. idle-watchdog closeoutUnit + normal loop closeoutUnit). + // A unit is considered a duplicate when type, id, AND startedAt all match + // an existing entry. On duplicate, the existing entry is updated in-place + // with the latest finishedAt and token counts instead of appending. + const dupeIdx = ledger.units.findIndex( + (u) => u.type === unit.type && u.id === unit.id && u.startedAt === unit.startedAt, + ); + if (dupeIdx >= 0) { + ledger.units[dupeIdx] = unit; + } else { + ledger.units.push(unit); + } saveLedger(basePath, ledger); return unit; @@ -517,6 +530,31 @@ function defaultLedger(): MetricsLedger { return { version: 1, projectStartedAt: Date.now(), units: [] }; } +/** + * Prune the metrics ledger to at most `keepCount` most-recent unit entries. + * + * Called by the doctor when the ledger exceeds the bloat threshold. + * Keeps the newest entries (highest index = most recent) and discards + * the oldest from the head of the array. Preserves `projectStartedAt`. + * + * Updates both the on-disk file and the in-memory ledger if it is loaded, + * so the current session sees the pruned state immediately. + * + * @returns the number of entries removed, or 0 if no pruning was needed. + */ +export function pruneMetricsLedger(base: string, keepCount: number): number { + const disk = loadLedgerFromDisk(base); + if (!disk || disk.units.length <= keepCount) return 0; + const removed = disk.units.length - keepCount; + disk.units = disk.units.slice(-keepCount); + saveJsonFile(metricsPath(base), disk); + // Keep the in-memory ledger in sync if it is loaded for this session. + if (ledger) { + ledger.units = ledger.units.slice(-keepCount); + } + return removed; +} + /** * Load ledger from disk without initializing in-memory state. * Used by history/export commands outside of auto-mode. diff --git a/src/resources/extensions/gsd/migrate/command.ts b/src/resources/extensions/gsd/migrate/command.ts index 233ab61f3..f2567c640 100644 --- a/src/resources/extensions/gsd/migrate/command.ts +++ b/src/resources/extensions/gsd/migrate/command.ts @@ -14,7 +14,7 @@ import { existsSync, readFileSync } from "node:fs"; import { resolve, join, dirname } from "node:path"; import { gsdRoot } from "../paths.js"; import { fileURLToPath } from "node:url"; -import { showNextAction } from "../../shared/mod.js"; +import { showNextAction } from "../../shared/tui.js"; import { validatePlanningDirectory, parsePlanningDirectory, diff --git a/src/resources/extensions/gsd/milestone-id-utils.ts b/src/resources/extensions/gsd/milestone-id-utils.ts new file mode 100644 index 000000000..c2d4e2c0d --- /dev/null +++ b/src/resources/extensions/gsd/milestone-id-utils.ts @@ -0,0 +1,32 @@ +import { readdirSync } from "node:fs"; + +import { milestonesDir } from "./paths.js"; + +/** Matches both classic `M001` and unique `M001-abc123` formats (anchored). */ +export const MILESTONE_ID_RE = /^M\d{3}(?:-[a-z0-9]{6})?$/; + +/** Extract the trailing sequential number from a milestone ID. Returns 0 for non-matches. */ +export function extractMilestoneSeq(id: string): number { + const match = id.match(/^M(\d{3})(?:-[a-z0-9]{6})?$/); + return match ? parseInt(match[1], 10) : 0; +} + +/** Comparator for sorting milestone IDs by sequential number. */ +export function milestoneIdSort(a: string, b: string): number { + return extractMilestoneSeq(a) - extractMilestoneSeq(b); +} + +export function findMilestoneIds(basePath: string): string[] { + const dir = milestonesDir(basePath); + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const match = entry.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/); + return match ? match[1] : entry.name; + }) + .sort(milestoneIdSort); + } catch { + return []; + } +} diff --git a/src/resources/extensions/gsd/milestone-ids.ts b/src/resources/extensions/gsd/milestone-ids.ts index fdd26f7ab..aa44c8f87 100644 --- a/src/resources/extensions/gsd/milestone-ids.ts +++ b/src/resources/extensions/gsd/milestone-ids.ts @@ -70,6 +70,44 @@ export function nextMilestoneId(milestoneIds: string[], uniqueEnabled?: boolean) return `M${seq}`; } +// ─── Reservation ───────────────────────────────────────────────────────────── + +/** + * Module-level set of milestone IDs that have been previewed/promised to the + * user but not yet materialised on disk. Both guided-flow (preview) and + * gsd_milestone_generate_id (tool) share this set so the ID shown in the UI + * matches the one the tool returns. + */ +const reservedMilestoneIds = new Set(); + +/** Reserve an ID so that subsequent calls to `claimReservedId` / `nextMilestoneId` account for it. */ +export function reserveMilestoneId(id: string): void { + reservedMilestoneIds.add(id); +} + +/** + * If any IDs have been reserved, shift one out and return it. + * Returns `undefined` when the reservation set is empty. + */ +export function claimReservedId(): string | undefined { + const first = reservedMilestoneIds.values().next().value; + if (first !== undefined) { + reservedMilestoneIds.delete(first); + return first; + } + return undefined; +} + +/** Return a snapshot of all currently reserved IDs (for merging into the "existing" list). */ +export function getReservedMilestoneIds(): ReadonlySet { + return reservedMilestoneIds; +} + +/** Clear all reservations (useful for tests). */ +export function clearReservedMilestoneIds(): void { + reservedMilestoneIds.clear(); +} + // ─── Discovery ────────────────────────────────────────────────────────────── /** Scan the milestones directory and return IDs sorted by queue order (or numeric fallback). */ diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index d091da965..ab2361296 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -207,7 +207,9 @@ export function nativeDetectMainBranch(basePath: string): string { /** * Check if a local branch exists. * Native: checks refs/heads/ via libgit2. - * Fallback: `git show-ref --verify`. + * Fallback: `git show-ref --verify`, with unborn-branch detection + * so that the current branch in a zero-commit repo is treated as + * existing (fixes #1771). */ export function nativeBranchExists(basePath: string, branch: string): boolean { const native = loadNative(); @@ -215,7 +217,12 @@ export function nativeBranchExists(basePath: string, branch: string): boolean { return native.gitBranchExists(basePath, branch); } const result = gitExec(basePath, ["show-ref", "--verify", `refs/heads/${branch}`], true); - return result !== ""; + if (result !== "") return true; + + // show-ref fails for unborn branches (zero commits). Fall back to checking + // whether the requested branch is the current (unborn) branch. + const current = gitExec(basePath, ["branch", "--show-current"], true); + return current === branch; } /** @@ -698,12 +705,19 @@ export function nativeAddAllWithExclusions(basePath: string, exclusions: readonl env: GIT_NO_PROMPT_ENV, }); } catch (err: unknown) { + const stderr = (err as { stderr?: string })?.stderr ?? ""; // git exits 1 when pathspec exclusions reference paths already covered // by .gitignore. The staging itself succeeds — only suppress that case. - const stderr = (err as { stderr?: string })?.stderr ?? ""; if (stderr.includes("ignored by one of your .gitignore files")) { return; } + // When .gsd is a symlink, git rejects `:!.gsd/...` pathspecs with + // "beyond a symbolic link". Fall back to plain `git add -A` which + // respects .gitignore (where .gsd/ is listed by default). + if (stderr.includes("beyond a symbolic link")) { + nativeAddAll(basePath); + return; + } throw new GSDError(GSD_GIT_ERROR, `git add -A with exclusions failed in ${basePath}: ${getErrorMessage(err)}`); } } @@ -794,7 +808,7 @@ export function nativeCheckoutBranch(basePath: string, branch: string): void { native.gitCheckoutBranch(basePath, branch); return; } - execSync(`git checkout ${branch}`, { + execFileSync("git", ["checkout", branch], { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", @@ -829,17 +843,37 @@ export function nativeMergeSquash(basePath: string, branch: string): GitMergeRes } try { - execSync(`git merge --squash ${branch}`, { + execFileSync("git", ["merge", "--squash", branch], { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", }); return { success: true, conflicts: [] }; - } catch { - // Check for conflicts + } catch (err: unknown) { + // Distinguish pre-merge rejections (dirty working tree) from actual + // content conflicts. When git rejects the merge before staging + // ("local changes would be overwritten"), there are no conflict markers + // to detect, so the old --diff-filter=U check would return an empty + // list and incorrectly report success (#1672, #1738). + const stderr = + err instanceof Error ? (err as Error & { stderr?: string }).stderr ?? err.message : String(err); + if ( + stderr.includes("local changes would be overwritten") || + stderr.includes("not possible because you have unmerged files") || + stderr.includes("overwritten by merge") + ) { + return { success: false, conflicts: ["__dirty_working_tree__"] }; + } + + // Check for real content conflicts const conflictOutput = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true); const conflicts = conflictOutput ? conflictOutput.split("\n").filter(Boolean) : []; - return { success: conflicts.length === 0, conflicts }; + if (conflicts.length > 0) { + return { success: false, conflicts }; + } + // No conflicts detected — this is a non-conflict failure; re-throw + // so the caller knows the merge did not succeed. + throw err; } } @@ -1065,6 +1099,62 @@ export function isNativeGitAvailable(): boolean { return loadNative() !== null; } +/** + * Check if a commit/branch is an ancestor of another. + * Returns true if `ancestor` is reachable from `descendant`. + * Fallback: `git merge-base --is-ancestor`. + */ +export function nativeIsAncestor(basePath: string, ancestor: string, descendant: string): boolean { + try { + execFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + env: GIT_NO_PROMPT_ENV, + }); + return true; + } catch { + return false; + } +} + +/** + * Get the Unix epoch (seconds) of the latest commit on a ref. + * Returns 0 if the ref doesn't exist or has no commits. + * Fallback: `git log -1 --format=%ct `. + */ +export function nativeLastCommitEpoch(basePath: string, ref: string): number { + try { + const result = execFileSync("git", ["log", "-1", "--format=%ct", ref], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, + }).trim(); + return parseInt(result, 10) || 0; + } catch { + return 0; + } +} + +/** + * Count commits on `branch` that are not on any remote tracking branch. + * Returns the count of unpushed commits, or -1 if the branch has no upstream. + * Fallback: `git rev-list --not --remotes`. + */ +export function nativeUnpushedCount(basePath: string, branch: string): number { + try { + const result = execFileSync("git", ["rev-list", branch, "--not", "--remotes", "--count"], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, + }).trim(); + return parseInt(result, 10) || 0; + } catch { + return -1; + } +} + // ─── Re-exports for type consumers ────────────────────────────────────── export type { diff --git a/src/resources/extensions/gsd/parallel-orchestrator.ts b/src/resources/extensions/gsd/parallel-orchestrator.ts index 66adbdf88..86aa480f7 100644 --- a/src/resources/extensions/gsd/parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/parallel-orchestrator.ts @@ -9,6 +9,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { + appendFileSync, existsSync, writeFileSync, readFileSync, @@ -29,6 +30,7 @@ import type { ParallelConfig } from "./types.js"; import { writeSessionStatus, readAllSessionStatuses, + readSessionStatus, removeSessionStatus, sendSignal, cleanupStaleSessions, @@ -181,6 +183,92 @@ export function restoreState(basePath: string): PersistedState | null { } } +function workerLogPath(basePath: string, milestoneId: string): string { + return join(gsdRoot(basePath), "parallel", `${milestoneId}.stderr.log`); +} + +function appendWorkerLog(basePath: string, milestoneId: string, chunk: string): void { + try { + const dir = join(gsdRoot(basePath), "parallel"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + appendFileSync(workerLogPath(basePath, milestoneId), chunk, "utf-8"); + } catch { + // Non-fatal — diagnostics should never break orchestration. + } +} + +function restoreRuntimeState(basePath: string): boolean { + if (state?.active) return true; + + const restored = restoreState(basePath); + if (restored && restored.workers.length > 0) { + const config = resolveParallelConfig(undefined); + state = { + active: restored.active, + workers: new Map(), + config: { + ...config, + max_workers: restored.configSnapshot.max_workers, + budget_ceiling: restored.configSnapshot.budget_ceiling, + }, + totalCost: restored.totalCost, + startedAt: restored.startedAt, + }; + + for (const w of restored.workers) { + const diskStatus = readSessionStatus(basePath, w.milestoneId); + state.workers.set(w.milestoneId, { + milestoneId: w.milestoneId, + title: w.title, + pid: diskStatus?.pid ?? w.pid, + process: null, + worktreePath: diskStatus?.worktreePath ?? w.worktreePath, + startedAt: w.startedAt, + state: diskStatus?.state ?? w.state, + completedUnits: diskStatus?.completedUnits ?? w.completedUnits, + cost: diskStatus?.cost ?? w.cost, + }); + } + + return true; + } + + // Fallback: rebuild coordinator state from live session status files. + // This covers cases where orchestrator.json is missing/corrupt but workers are + // still running and writing heartbeats under .gsd/parallel/. + cleanupStaleSessions(basePath); + const statuses = readAllSessionStatuses(basePath); + if (statuses.length === 0) { + return false; + } + + const config = resolveParallelConfig(undefined); + state = { + active: true, + workers: new Map(), + config, + totalCost: 0, + startedAt: Math.min(...statuses.map((status) => status.startedAt)), + }; + + for (const status of statuses) { + state.workers.set(status.milestoneId, { + milestoneId: status.milestoneId, + title: status.milestoneId, + pid: status.pid, + process: null, + worktreePath: status.worktreePath, + startedAt: status.startedAt, + state: status.state, + completedUnits: status.completedUnits, + cost: status.cost, + }); + state.totalCost += status.cost; + } + + return true; +} + async function waitForWorkerExit(worker: WorkerInfo, timeoutMs: number): Promise { if (worker.process) { await new Promise((resolve) => { @@ -202,6 +290,7 @@ async function waitForWorkerExit(worker: WorkerInfo, timeoutMs: number): Promise return !isPidAlive(worker.pid); } + // ─── Accessors ───────────────────────────────────────────────────────────── /** Returns true if the orchestrator is active and has been initialized. */ @@ -215,7 +304,10 @@ export function getOrchestratorState(): OrchestratorState | null { } /** Returns a snapshot of all tracked workers as an array. */ -export function getWorkerStatuses(): WorkerInfo[] { +export function getWorkerStatuses(basePath?: string): WorkerInfo[] { + if (basePath) { + refreshWorkerStatuses(basePath, { restoreIfNeeded: true }); + } if (!state) return []; return [...state.workers.values()]; } @@ -431,6 +523,11 @@ export function spawnWorker( env: { ...process.env, GSD_MILESTONE_LOCK: milestoneId, + // Pass the real project root so workers don't need to re-derive it. + // Without this, process.cwd() resolves symlinks and the worktree + // path heuristic can match the user-level ~/.gsd instead of the + // project .gsd, causing writes to ~ and corrupting user config. + GSD_PROJECT_ROOT: basePath, // Prevent workers from spawning their own parallel sessions GSD_PARALLEL_WORKER: "1", }, @@ -482,6 +579,12 @@ export function spawnWorker( }); } + if (child.stderr) { + child.stderr.on("data", (data: Buffer) => { + appendWorkerLog(basePath, milestoneId, data.toString()); + }); + } + // Update session status with real PID writeSessionStatus(basePath, { milestoneId, @@ -508,6 +611,7 @@ export function spawnWorker( w.state = "stopped"; } else { w.state = "error"; + appendWorkerLog(basePath, milestoneId, `\n[orchestrator] worker exited with code ${code ?? "null"}\n`); } // Update session status and persist orchestrator state for crash recovery @@ -762,7 +866,13 @@ export function resumeWorker( * Poll worker statuses from disk and update orchestrator state. * Call this periodically from the dashboard refresh cycle. */ -export function refreshWorkerStatuses(basePath: string): void { +export function refreshWorkerStatuses( + basePath: string, + options: { restoreIfNeeded?: boolean } = {}, +): void { + if (!state && options.restoreIfNeeded) { + restoreRuntimeState(basePath); + } if (!state) return; // Clean up stale sessions first @@ -785,7 +895,13 @@ export function refreshWorkerStatuses(basePath: string): void { // Update in-memory worker state from disk data for (const [mid, worker] of state.workers) { const diskStatus = statusMap.get(mid); - if (!diskStatus) continue; + if (!diskStatus) { + if (!isPidAlive(worker.pid)) { + worker.state = worker.completedUnits > 0 ? "stopped" : "error"; + worker.process = null; + } + continue; + } worker.state = diskStatus.state; worker.completedUnits = diskStatus.completedUnits; diff --git a/src/resources/extensions/gsd/post-unit-hooks.ts b/src/resources/extensions/gsd/post-unit-hooks.ts index c4e598980..4425a3f19 100644 --- a/src/resources/extensions/gsd/post-unit-hooks.ts +++ b/src/resources/extensions/gsd/post-unit-hooks.ts @@ -1,524 +1,86 @@ -// GSD Extension — Hook Engine (Post-Unit, Pre-Dispatch, State Persistence) -// Manages hook queue, cycle tracking, artifact verification, pre-dispatch -// interception, and durable hook state for user-configured extensibility. +// GSD Extension — Hook Engine Facade +// +// Thin facade over RuleRegistry. All mutable state and logic lives in the +// registry instance; these exported functions delegate through getOrCreateRegistry() +// so existing call-sites and tests work without modification. import type { - PostUnitHookConfig, - PreDispatchHookConfig, HookExecutionState, HookDispatchResult, PreDispatchResult, - PersistedHookState, HookStatusEntry, } from "./types.js"; -import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; +import { getOrCreateRegistry, resolveHookArtifactPath } from "./rule-registry.js"; -// ─── Hook Queue State ────────────────────────────────────────────────────── +// Re-export resolveHookArtifactPath so existing importers still work. +export { resolveHookArtifactPath } from "./rule-registry.js"; -/** Currently executing hook, or null if in normal dispatch flow. */ -let activeHook: HookExecutionState | null = null; +// ─── Post-Unit Hooks ─────────────────────────────────────────────────────── -/** Queue of hooks remaining for the current trigger unit. */ -let hookQueue: Array<{ - config: PostUnitHookConfig; - triggerUnitType: string; - triggerUnitId: string; -}> = []; - -/** Cycle counts per hook+trigger, keyed as "hookName/triggerUnitType/triggerUnitId". */ -const cycleCounts = new Map(); - -/** Set when a hook completes with retry_on artifact present — signals caller to re-run trigger. */ -let retryPending = false; - -/** Stores the trigger unit info for pending retries so caller knows what to re-run. */ -let retryTrigger: { unitType: string; unitId: string } | null = null; - -// ─── Public API ──────────────────────────────────────────────────────────── - -/** - * Called after a unit completes. Returns the next hook unit to dispatch, - * or null if no hooks apply (normal dispatch should proceed). - * - * Call flow: - * 1. A core unit (e.g. execute-task) completes → handleAgentEnd calls this - * 2. If hooks match, returns first hook to dispatch. Caller sends the prompt. - * 3. Hook unit completes → handleAgentEnd calls this again (activeHook is set) - * 4. Checks retry_on / next hook / done → returns next action or null - */ export function checkPostUnitHooks( completedUnitType: string, completedUnitId: string, basePath: string, ): HookDispatchResult | null { - // If we just completed a hook unit, handle its result - if (activeHook) { - return handleHookCompletion(basePath); - } - - // Don't trigger hooks for other hook units (prevent hook-on-hook chains) - // Don't trigger hooks for triage units (prevent hook-on-triage chains) - // Don't trigger hooks for quick-task units (lightweight one-offs from captures) - if (completedUnitType.startsWith("hook/") || completedUnitType === "triage-captures" || completedUnitType === "quick-task") return null; - - // Check if any hooks are configured for this unit type - const hooks = resolvePostUnitHooks().filter(h => - h.after.includes(completedUnitType), - ); - if (hooks.length === 0) return null; - - // Build hook queue for this trigger - hookQueue = hooks.map(config => ({ - config, - triggerUnitType: completedUnitType, - triggerUnitId: completedUnitId, - })); - - return dequeueNextHook(basePath); + return getOrCreateRegistry().evaluatePostUnit(completedUnitType, completedUnitId, basePath); } -/** - * Returns whether a hook is currently active (for progress display). - */ export function getActiveHook(): HookExecutionState | null { - return activeHook; + return getOrCreateRegistry().getActiveHook(); } -/** - * Returns true if a retry of the trigger unit was requested by a hook. - * Caller should re-dispatch the original trigger unit, then hooks will - * fire again on its next completion. - */ export function isRetryPending(): boolean { - return retryPending; + return getOrCreateRegistry().isRetryPending(); } -/** - * Returns the trigger unit info for a pending retry, or null. - * Clears the retry state after reading. - */ -export function consumeRetryTrigger(): { unitType: string; unitId: string } | null { - if (!retryPending || !retryTrigger) return null; - const trigger = { ...retryTrigger }; - retryPending = false; - retryTrigger = null; - return trigger; +export function consumeRetryTrigger(): { unitType: string; unitId: string; retryArtifact: string } | null { + return getOrCreateRegistry().consumeRetryTrigger(); } -/** - * Reset all hook state. Called on auto-mode start/stop. - */ export function resetHookState(): void { - activeHook = null; - hookQueue = []; - cycleCounts.clear(); - retryPending = false; - retryTrigger = null; + getOrCreateRegistry().resetState(); } -// ─── Internal ────────────────────────────────────────────────────────────── +// ─── Pre-Dispatch Hooks ──────────────────────────────────────────────────── -function dequeueNextHook(basePath: string): HookDispatchResult | null { - while (hookQueue.length > 0) { - const entry = hookQueue.shift()!; - const { config, triggerUnitType, triggerUnitId } = entry; - - // Check idempotency — if artifact already exists, skip this hook - if (config.artifact) { - const artifactPath = resolveHookArtifactPath(basePath, triggerUnitId, config.artifact); - if (existsSync(artifactPath)) continue; - } - - // Check cycle limit - const cycleKey = `${config.name}/${triggerUnitType}/${triggerUnitId}`; - const currentCycle = (cycleCounts.get(cycleKey) ?? 0) + 1; - const maxCycles = config.max_cycles ?? 1; - if (currentCycle > maxCycles) continue; - - cycleCounts.set(cycleKey, currentCycle); - - activeHook = { - hookName: config.name, - triggerUnitType, - triggerUnitId, - cycle: currentCycle, - pendingRetry: false, - }; - - // Build the prompt with variable substitution - const [mid, sid, tid] = triggerUnitId.split("/"); - let prompt = config.prompt - .replace(/\{milestoneId\}/g, mid ?? "") - .replace(/\{sliceId\}/g, sid ?? "") - .replace(/\{taskId\}/g, tid ?? ""); - - // Inject browser safety instruction for hooks that may use browser tools (#1345). - // Vite HMR and other persistent connections prevent networkidle from resolving. - prompt += "\n\n**Browser tool safety:** Do NOT use `browser_wait_for` with `condition: \"network_idle\"` — it hangs indefinitely when dev servers keep persistent connections (Vite HMR, WebSocket). Use `selector_visible`, `text_visible`, or `delay` instead."; - - return { - hookName: config.name, - prompt, - model: config.model, - unitType: `hook/${config.name}`, - unitId: triggerUnitId, - }; - } - - // No more hooks — clear active state and return null for normal dispatch - activeHook = null; - return null; -} - -function handleHookCompletion(basePath: string): HookDispatchResult | null { - const hook = activeHook!; - const hooks = resolvePostUnitHooks(); - const config = hooks.find(h => h.name === hook.hookName); - - // Check if retry was requested via retry_on artifact - if (config?.retry_on) { - const retryArtifactPath = resolveHookArtifactPath(basePath, hook.triggerUnitId, config.retry_on); - if (existsSync(retryArtifactPath)) { - // Check cycle limit before allowing retry - const cycleKey = `${config.name}/${hook.triggerUnitType}/${hook.triggerUnitId}`; - const currentCycle = cycleCounts.get(cycleKey) ?? 1; - const maxCycles = config.max_cycles ?? 1; - - if (currentCycle < maxCycles) { - // Signal retry — caller will re-dispatch the trigger unit - activeHook = null; - hookQueue = []; - retryPending = true; - retryTrigger = { unitType: hook.triggerUnitType, unitId: hook.triggerUnitId }; - return null; - } - // Max cycles reached — fall through to normal completion - } - } - - // Hook completed normally — try next hook in queue - activeHook = null; - return dequeueNextHook(basePath); -} - -/** - * Resolve the path where a hook artifact is expected to be written. - * Uses the trigger unit's directory context: - * - Task-level (M001/S01/T01): .gsd/M001/slices/S01/tasks/T01-{artifact} - * - Slice-level (M001/S01): .gsd/M001/slices/S01/{artifact} - * - Milestone-level (M001): .gsd/M001/{artifact} - */ -export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string { - const parts = unitId.split("/"); - if (parts.length === 3) { - const [mid, sid, tid] = parts; - return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`); - } - if (parts.length === 2) { - const [mid, sid] = parts; - return join(basePath, ".gsd", mid, "slices", sid, artifactName); - } - return join(basePath, ".gsd", parts[0], artifactName); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Phase 2: Pre-Dispatch Hooks -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Run pre-dispatch hooks for a unit about to be dispatched. - * Returns a result indicating whether the unit should proceed (with optional - * prompt modifications), be skipped, or be replaced entirely. - * - * Multiple hooks can fire for the same unit type. They compose: - * - "modify" hooks stack (all prepend/append applied in order) - * - "skip" short-circuits (first matching skip wins) - * - "replace" short-circuits (first matching replace wins) - * - Skip/replace hooks take precedence over modify hooks - */ export function runPreDispatchHooks( unitType: string, unitId: string, prompt: string, basePath: string, ): PreDispatchResult { - // Don't intercept hook units - if (unitType.startsWith("hook/")) { - return { action: "proceed", prompt, firedHooks: [] }; - } - - const hooks = resolvePreDispatchHooks().filter(h => - h.before.includes(unitType), - ); - if (hooks.length === 0) { - return { action: "proceed", prompt, firedHooks: [] }; - } - - const [mid, sid, tid] = unitId.split("/"); - const substitute = (text: string): string => - text - .replace(/\{milestoneId\}/g, mid ?? "") - .replace(/\{sliceId\}/g, sid ?? "") - .replace(/\{taskId\}/g, tid ?? ""); - - const firedHooks: string[] = []; - let currentPrompt = prompt; - - for (const hook of hooks) { - if (hook.action === "skip") { - // Check optional skip condition - if (hook.skip_if) { - const conditionPath = resolveHookArtifactPath(basePath, unitId, hook.skip_if); - if (!existsSync(conditionPath)) continue; // Condition not met, don't skip - } - firedHooks.push(hook.name); - return { action: "skip", firedHooks }; - } - - if (hook.action === "replace") { - firedHooks.push(hook.name); - return { - action: "replace", - prompt: substitute(hook.prompt ?? ""), - unitType: hook.unit_type, - model: hook.model, - firedHooks, - }; - } - - if (hook.action === "modify") { - firedHooks.push(hook.name); - if (hook.prepend) { - currentPrompt = `${substitute(hook.prepend)}\n\n${currentPrompt}`; - } - if (hook.append) { - currentPrompt = `${currentPrompt}\n\n${substitute(hook.append)}`; - } - } - } - - return { - action: "proceed", - prompt: currentPrompt, - model: hooks.find(h => h.action === "modify" && h.model)?.model, - firedHooks, - }; + return getOrCreateRegistry().evaluatePreDispatch(unitType, unitId, prompt, basePath); } -// ═══════════════════════════════════════════════════════════════════════════ -// Phase 3: Hook State Persistence -// ═══════════════════════════════════════════════════════════════════════════ +// ─── State Persistence ───────────────────────────────────────────────────── -const HOOK_STATE_FILE = "hook-state.json"; - -function hookStatePath(basePath: string): string { - return join(basePath, ".gsd", HOOK_STATE_FILE); -} - -/** - * Persist current hook cycle counts to disk so they survive crashes/restarts. - * Called after each hook dispatch and on auto-mode pause. - */ export function persistHookState(basePath: string): void { - const state: PersistedHookState = { - cycleCounts: Object.fromEntries(cycleCounts), - savedAt: new Date().toISOString(), - }; - try { - const dir = join(basePath, ".gsd"); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8"); - } catch { - // Non-fatal — state is recreatable from artifacts - } + getOrCreateRegistry().persistState(basePath); } -/** - * Restore hook cycle counts from disk after a crash/restart. - * Called during auto-mode resume. - */ export function restoreHookState(basePath: string): void { - try { - const filePath = hookStatePath(basePath); - if (!existsSync(filePath)) return; - const raw = readFileSync(filePath, "utf-8"); - const state: PersistedHookState = JSON.parse(raw); - if (state.cycleCounts && typeof state.cycleCounts === "object") { - cycleCounts.clear(); - for (const [key, value] of Object.entries(state.cycleCounts)) { - if (typeof value === "number") { - cycleCounts.set(key, value); - } - } - } - } catch { - // Non-fatal — fresh state is fine - } + getOrCreateRegistry().restoreState(basePath); } -/** - * Clear persisted hook state file from disk. - * Called on clean auto-mode stop. - */ export function clearPersistedHookState(basePath: string): void { - try { - const filePath = hookStatePath(basePath); - if (existsSync(filePath)) { - writeFileSync(filePath, JSON.stringify({ cycleCounts: {}, savedAt: new Date().toISOString() }, null, 2), "utf-8"); - } - } catch { - // Non-fatal - } + getOrCreateRegistry().clearPersistedState(basePath); } -// ═══════════════════════════════════════════════════════════════════════════ -// Phase 3: Hook Status Reporting -// ═══════════════════════════════════════════════════════════════════════════ +// ─── Status & Manual Trigger ─────────────────────────────────────────────── -/** - * Get status of all configured hooks for display by /gsd hooks. - */ export function getHookStatus(): HookStatusEntry[] { - const entries: HookStatusEntry[] = []; - - // Post-unit hooks - const postHooks = resolvePostUnitHooks(); - for (const hook of postHooks) { - const activeCycles: Record = {}; - for (const [key, count] of cycleCounts) { - if (key.startsWith(`${hook.name}/`)) { - activeCycles[key] = count; - } - } - entries.push({ - name: hook.name, - type: "post", - enabled: hook.enabled !== false, - targets: hook.after, - activeCycles, - }); - } - - // Pre-dispatch hooks - const preHooks = resolvePreDispatchHooks(); - for (const hook of preHooks) { - entries.push({ - name: hook.name, - type: "pre", - enabled: hook.enabled !== false, - targets: hook.before, - activeCycles: {}, - }); - } - - return entries; + return getOrCreateRegistry().getHookStatus(); } -/** - * Manually trigger a specific hook for a unit. - * This bypasses the normal flow and forces the hook to run even if its artifact exists. - * - * @param hookName - The name of the hook to trigger (e.g., "code-review") - * @param unitType - The type of unit that triggered the hook (e.g., "execute-task") - * @param unitId - The unit ID (e.g., "M001/S01/T01") - * @param basePath - The project base path - * @returns The hook dispatch result or null if hook not found - */ export function triggerHookManually( hookName: string, unitType: string, unitId: string, basePath: string, ): HookDispatchResult | null { - // Find the hook configuration - const hook = resolvePostUnitHooks().find(h => h.name === hookName); - if (!hook) { - console.error(`[triggerHookManually] Hook "${hookName}" not found in post_unit_hooks`); - return null; - } - - if (!hook.prompt || typeof hook.prompt !== 'string' || hook.prompt.trim().length === 0) { - console.error(`[triggerHookManually] Hook "${hookName}" has empty prompt`); - return null; - } - - // Reset any active hook state to allow manual triggering - activeHook = { - hookName: hook.name, - triggerUnitType: unitType, - triggerUnitId: unitId, - cycle: 1, - pendingRetry: false, - }; - - // Build the hook queue with just this hook - hookQueue = [{ - config: hook, - triggerUnitType: unitType, - triggerUnitId: unitId, - }]; - - // Set the cycle count for this specific hook+trigger - const cycleKey = `${hook.name}/${unitType}/${unitId}`; - const currentCycle = (cycleCounts.get(cycleKey) ?? 0) + 1; - cycleCounts.set(cycleKey, currentCycle); - - // Update active hook with the cycle count - activeHook.cycle = currentCycle; - - // Build the prompt with variable substitution - const [mid, sid, tid] = unitId.split("/"); - const prompt = hook.prompt - .replace(/\{milestoneId\}/g, mid ?? "") - .replace(/\{sliceId\}/g, sid ?? "") - .replace(/\{taskId\}/g, tid ?? ""); - - console.log(`[triggerHookManually] Built prompt for ${hookName}, length: ${prompt.length}`); - - return { - hookName: hook.name, - prompt, - model: hook.model, - unitType: `hook/${hook.name}`, - unitId, - }; + return getOrCreateRegistry().triggerHookManually(hookName, unitType, unitId, basePath); } -/** - * Format hook status for terminal display. - */ export function formatHookStatus(): string { - const entries = getHookStatus(); - if (entries.length === 0) { - return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .gsd/preferences.md"; - } - - const lines: string[] = ["Configured Hooks:", ""]; - - const postHooks = entries.filter(e => e.type === "post"); - const preHooks = entries.filter(e => e.type === "pre"); - - if (postHooks.length > 0) { - lines.push("Post-Unit Hooks (run after unit completes):"); - for (const hook of postHooks) { - const status = hook.enabled ? "enabled" : "disabled"; - const cycles = Object.keys(hook.activeCycles).length; - const cycleInfo = cycles > 0 ? ` (${cycles} active cycle${cycles === 1 ? "" : "s"})` : ""; - lines.push(` ${hook.name} [${status}] → after: ${hook.targets.join(", ")}${cycleInfo}`); - } - lines.push(""); - } - - if (preHooks.length > 0) { - lines.push("Pre-Dispatch Hooks (run before unit dispatches):"); - for (const hook of preHooks) { - const status = hook.enabled ? "enabled" : "disabled"; - lines.push(` ${hook.name} [${status}] → before: ${hook.targets.join(", ")}`); - } - lines.push(""); - } - - return lines.join("\n"); + return getOrCreateRegistry().formatHookStatus(); } diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index e14ca4a03..36e6f83f5 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -88,6 +88,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "widget_mode", "reactive_execution", "github", + "service_tier", ]); /** Canonical list of all dispatch unit types. */ @@ -98,6 +99,7 @@ export const KNOWN_UNIT_TYPES = [ ] as const; export type UnitType = (typeof KNOWN_UNIT_TYPES)[number]; + export const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]); export interface GSDSkillRule { @@ -219,6 +221,8 @@ export interface GSDPreferences { reactive_execution?: ReactiveExecutionConfig; /** GitHub sync configuration. Opt-in: syncs GSD events to GitHub Issues, Milestones, and PRs. */ github?: GitHubSyncConfig; + /** OpenAI service tier preference. "priority" = 2x cost, faster. "flex" = 0.5x cost, slower. Only affects gpt-5.4 models. */ + service_tier?: "priority" | "flex"; } export interface LoadedGSDPreferences { diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index ac3ac95d8..d19468a68 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -15,6 +15,7 @@ import { normalizeStringArray } from "../shared/format-utils.js"; import { KNOWN_PREFERENCE_KEYS, KNOWN_UNIT_TYPES, + SKILL_ACTIONS, type WorkflowMode, type GSDPreferences, diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 15f5c0b3c..e369525cc 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -285,6 +285,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr github: (base.github || override.github) ? { ...(base.github ?? {}), ...(override.github ?? {}) } as import("../github-sync/types.js").GitHubSyncConfig : undefined, + service_tier: override.service_tier ?? base.service_tier, }; } diff --git a/src/resources/extensions/gsd/prompt-loader.ts b/src/resources/extensions/gsd/prompt-loader.ts index b5937d7fa..b5e2a37ab 100644 --- a/src/resources/extensions/gsd/prompt-loader.ts +++ b/src/resources/extensions/gsd/prompt-loader.ts @@ -17,12 +17,36 @@ * that aren't read until the end of a long auto-mode run. */ -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, existsSync } from "node:fs"; import { GSDError, GSD_PARSE_ERROR } from "./errors.js"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; -const __extensionDir = dirname(fileURLToPath(import.meta.url)); +/** + * Resolve the GSD extension directory. + * + * `import.meta.url` resolves to whichever copy of this module is executing. + * On Windows (npm global install via MSYS2 / Git Bash) this can resolve to + * the npm-global `AppData/Roaming/npm/…` path, which does NOT contain the + * prompts/ and templates/ subtrees that initResources() copies to + * `~/.gsd/agent/extensions/gsd/`. Detect the mismatch and fall back to + * the user-local agent directory. + */ +function resolveExtensionDir(): string { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + if (existsSync(join(moduleDir, "prompts"))) return moduleDir; + + // Fallback: user-local agent directory + const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + const agentGsdDir = join(gsdHome, "agent", "extensions", "gsd"); + if (existsSync(join(agentGsdDir, "prompts"))) return agentGsdDir; + + // Last resort: return the module dir (warmCache will silently handle the miss) + return moduleDir; +} + +const __extensionDir = resolveExtensionDir(); const promptsDir = join(__extensionDir, "prompts"); const templatesDir = join(__extensionDir, "templates"); @@ -45,7 +69,11 @@ function warmCache(): void { } } } catch { - // prompts/ may not exist in test environments — lazy loading still works + // prompts/ may not exist in test environments — lazy loading still works. + // Emit a diagnostic when running outside tests so wrong-path bugs are visible. + if (!process.env.VITEST && !process.env.NODE_TEST) { + process.stderr.write(`[gsd:prompt-loader] warmCache: prompts dir not found: ${promptsDir}\n`); + } } try { @@ -57,7 +85,10 @@ function warmCache(): void { } } } catch { - // templates/ may not exist in test environments — lazy loading still works + // templates/ may not exist in test environments — lazy loading still works. + if (!process.env.VITEST && !process.env.NODE_TEST) { + process.stderr.write(`[gsd:prompt-loader] warmCache: templates dir not found: ${templatesDir}\n`); + } } } diff --git a/src/resources/extensions/gsd/prompts/discuss-headless.md b/src/resources/extensions/gsd/prompts/discuss-headless.md index b6b814064..9de3bcd2a 100644 --- a/src/resources/extensions/gsd/prompts/discuss-headless.md +++ b/src/resources/extensions/gsd/prompts/discuss-headless.md @@ -56,7 +56,7 @@ Use these templates exactly: 9. Say exactly: "Milestone {{milestoneId}} ready." **For multi-milestone**, write in this order: -1. For each milestone, call `gsd_generate_milestone_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices` for each. +1. For each milestone, call `gsd_milestone_generate_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices` for each. 2. Write `.gsd/PROJECT.md` — full vision across ALL milestones (using Project template) 3. Write `.gsd/REQUIREMENTS.md` — full capability contract (using Requirements template) 4. Seed `.gsd/DECISIONS.md` (using Decisions template) @@ -82,5 +82,5 @@ Use these templates exactly: - **Investigate before writing** — always scout the codebase first - **Use depends_on frontmatter** for multi-milestone sequences (the state machine reads this field to determine execution order) - **Anti-reduction rule** — if the spec describes a big vision, plan the big vision. Do not ask "what's the minimum viable version?" or reduce scope. Phase complex/risky work into later milestones — do not cut it. -- **Naming convention** — always use `gsd_generate_milestone_id` to get milestone IDs. Directories use bare IDs (e.g. `M001/` or `M001-r5jzab/`), files use ID-SUFFIX format (e.g. `M001-CONTEXT.md` or `M001-r5jzab-CONTEXT.md`). Never invent milestone IDs manually. +- **Naming convention** — always use `gsd_milestone_generate_id` to get milestone IDs. Directories use bare IDs (e.g. `M001/` or `M001-r5jzab/`), files use ID-SUFFIX format (e.g. `M001-CONTEXT.md` or `M001-r5jzab-CONTEXT.md`). Never invent milestone IDs manually. - **End with "Milestone {{milestoneId}} ready."** — this triggers auto-start detection diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index 28b988551..38c71647d 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -36,9 +36,16 @@ Before asking your first question, do a mandatory investigation pass. This is no 2. **Check library docs** — `resolve_library` / `get_library_docs` for any tech the user mentioned. Get current facts about capabilities, constraints, API shapes, version-specific behavior. 3. **Web search** — `search-the-web` if the domain is unfamiliar, if you need current best practices, or if the user referenced external services/APIs you need facts about. Use `fetch_page` for full content when snippets aren't enough. +**Web search budget:** You have a limited number of web searches per turn (typically 3-5). The discuss phase spans many turns (investigation, question rounds, focused research, requirements), so budget carefully: +- Prefer `resolve_library` / `get_library_docs` over `web_search` for library documentation — they don't consume the web search budget. +- Prefer `search_and_read` for one-shot topic research — it combines search + page fetch in a single call. +- Target 2-3 web searches in the investigation pass. Save remaining budget for the focused research pass before roadmap creation. +- Do NOT repeat the same or similar queries. If a search didn't find what you need, rephrase once or move on. +- When a search returns many results, each result contains multiple text spans — this is normal formatting, not separate searches. + This happens ONCE, before the first round. The goal: your first questions should reflect what's actually true, not what you assume. -For subsequent rounds, continue investigating between rounds — check docs, search, or scout as needed to make each round's questions smarter. But the first-round investigation is mandatory and explicit. +For subsequent rounds, continue investigating between rounds — check docs, search, or scout as needed to make each round's questions smarter. But the first-round investigation is mandatory and explicit. Distribute searches across turns rather than clustering them in one turn. ## Questioning Philosophy @@ -207,7 +214,7 @@ Once the user confirms the milestone split: #### Phase 1: Shared artifacts -1. For each milestone, call `gsd_generate_milestone_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. +1. For each milestone, call `gsd_milestone_generate_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. 2. Write `.gsd/PROJECT.md` — use the **Project** output template below. 3. Write `.gsd/REQUIREMENTS.md` — use the **Requirements** output template below. Capture Active, Deferred, Out of Scope, and any already Validated requirements. Later milestones may have provisional ownership where slice plans do not exist yet. 4. Seed `.gsd/DECISIONS.md` — use the **Decisions** output template below. diff --git a/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md b/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md index ef8e28c0e..55117dd2f 100644 --- a/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +++ b/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md @@ -13,8 +13,11 @@ Discuss milestone {{milestoneId}} ("{{milestoneTitle}}"). Identify gray areas, a Do a lightweight targeted investigation so your questions are grounded in reality: - Scout the codebase (`rg`, `find`, or `scout`) to understand what already exists that this milestone touches or builds on - Check the roadmap context above (if present) to understand what surrounds this milestone +- Use `resolve_library` / `get_library_docs` for unfamiliar libraries — prefer this over `web_search` for library documentation - Identify the 3–5 biggest behavioural and architectural unknowns: things where the user's answer will materially change what gets built +**Web search budget:** You have a limited number of web searches per turn (typically 3-5). Prefer `resolve_library` / `get_library_docs` for library documentation and `search_and_read` for one-shot topic research — they are more budget-efficient. Target 2-3 web searches in the investigation pass. Distribute remaining searches across subsequent question rounds rather than clustering them. + Do **not** go deep — just enough that your questions reflect what's actually true rather than what you assume. ### Question rounds diff --git a/src/resources/extensions/gsd/prompts/guided-discuss-slice.md b/src/resources/extensions/gsd/prompts/guided-discuss-slice.md index ff9176002..143f8a60f 100644 --- a/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +++ b/src/resources/extensions/gsd/prompts/guided-discuss-slice.md @@ -13,8 +13,11 @@ Your goal is **not** to center the discussion on tech stack trivia, naming conve Do a lightweight targeted investigation so your questions are grounded in reality: - Scout the codebase (`rg`, `find`, or `scout` for broad unfamiliar areas) to understand what already exists that this slice touches or builds on - Check the roadmap context above to understand what surrounds this slice — what comes before, what depends on it +- Use `resolve_library` / `get_library_docs` for unfamiliar libraries — prefer this over `web_search` for library documentation - Identify the 3–5 biggest behavioural unknowns: things where the user's answer will materially change what gets built +**Web search budget:** You have a limited number of web searches per turn (typically 3-5). Prefer `resolve_library` / `get_library_docs` for library documentation and `search_and_read` for one-shot topic research — they are more budget-efficient. Target 2-3 web searches in the investigation pass. Distribute remaining searches across subsequent question rounds rather than clustering them. + Do **not** go deep — just enough that your questions reflect what's actually true rather than what you assume. ### Question rounds diff --git a/src/resources/extensions/gsd/prompts/queue.md b/src/resources/extensions/gsd/prompts/queue.md index 28df62a44..15d8deb08 100644 --- a/src/resources/extensions/gsd/prompts/queue.md +++ b/src/resources/extensions/gsd/prompts/queue.md @@ -24,7 +24,7 @@ After they describe it, your job is to understand the new work deeply enough to **Investigate between question rounds to make your questions smarter.** Before each round of questions, do enough lightweight research that your questions are grounded in reality — not guesses about what exists or what's possible. - Check library docs (`resolve_library` / `get_library_docs`) when the user mentions tech you need current facts about — capabilities, constraints, API shapes, version-specific behavior -- Do web searches (`search-the-web`) to verify the landscape — what solutions exist, what's changed recently, what's the current best practice. Use `freshness` for recency-sensitive queries, `domain` to target specific sites. Use `fetch_page` to read the full content of promising URLs when snippets aren't enough. +- Do web searches (`search-the-web`) to verify the landscape — what solutions exist, what's changed recently, what's the current best practice. Use `freshness` for recency-sensitive queries, `domain` to target specific sites. Use `fetch_page` to read the full content of promising URLs when snippets aren't enough. **Budget:** You have a limited number of web searches per turn (typically 3-5). Prefer `resolve_library` / `get_library_docs` for library documentation and `search_and_read` for one-shot topic research. Do NOT repeat the same or similar queries. Distribute searches across turns rather than clustering them. - Scout the codebase (`ls`, `find`, `rg`, or `scout` for broad unfamiliar areas) to understand what already exists, what patterns are established, what constraints current code imposes Don't go deep — just enough that your next question reflects what's actually true rather than what you assume. @@ -107,7 +107,7 @@ The user confirms or corrects before you write. One depth verification per miles Once the user is satisfied, in a single pass for **each** new milestone: -1. Call `gsd_generate_milestone_id` to get the milestone ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. +1. Call `gsd_milestone_generate_id` to get the milestone ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. 2. Write `.gsd/milestones//-CONTEXT.md` — use the **Context** output template below. Capture intent, scope, risks, constraints, integration points, and relevant requirements. Mark the status as "Queued — pending auto-mode execution." **If this milestone depends on other milestones, add YAML frontmatter with `depends_on`:** ```yaml --- diff --git a/src/resources/extensions/gsd/queue-reorder-ui.ts b/src/resources/extensions/gsd/queue-reorder-ui.ts index e578b20fe..37ff600a1 100644 --- a/src/resources/extensions/gsd/queue-reorder-ui.ts +++ b/src/resources/extensions/gsd/queue-reorder-ui.ts @@ -11,7 +11,8 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { type Theme } from "@gsd/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, type TUI } from "@gsd/pi-tui"; -import { makeUI, GLYPH } from "../shared/mod.js"; +import { makeUI } from "../shared/tui.js"; +import { GLYPH } from "../shared/mod.js"; import { validateQueueOrder, type DependencyValidation } from "./queue-order.js"; export interface ReorderItem { diff --git a/src/resources/extensions/gsd/repo-identity.ts b/src/resources/extensions/gsd/repo-identity.ts index ae03e9ca2..e704c4f48 100644 --- a/src/resources/extensions/gsd/repo-identity.ts +++ b/src/resources/extensions/gsd/repo-identity.ts @@ -8,12 +8,137 @@ import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); +// ─── Repo Metadata ─────────────────────────────────────────────────────────── + +export interface RepoMeta { + version: number; + hash: string; + gitRoot: string; + remoteUrl: string; + createdAt: string; +} + +function isRepoMeta(value: unknown): value is RepoMeta { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.version === "number" + && typeof v.hash === "string" + && typeof v.gitRoot === "string" + && typeof v.remoteUrl === "string" + && typeof v.createdAt === "string"; +} + +/** + * Write (or refresh) repo metadata into the external state directory. + * Called on open so metadata tracks repo path moves while keeping createdAt stable. + * Non-fatal: a metadata write failure must never block project setup. + */ +function writeRepoMeta(externalPath: string, remoteUrl: string, gitRoot: string): void { + const metaPath = join(externalPath, "repo-meta.json"); + try { + let createdAt = new Date().toISOString(); + let existing: RepoMeta | null = null; + if (existsSync(metaPath)) { + try { + const parsed = JSON.parse(readFileSync(metaPath, "utf-8")); + if (isRepoMeta(parsed)) { + existing = parsed; + createdAt = parsed.createdAt; + // Fast path: nothing changed. + if ( + parsed.version === 1 + && parsed.hash === basename(externalPath) + && parsed.gitRoot === gitRoot + && parsed.remoteUrl === remoteUrl + ) { + return; + } + } + } catch { + // Fall through and rewrite invalid metadata. + } + } + + const meta: RepoMeta = { + version: 1, + hash: basename(externalPath), + gitRoot, + remoteUrl, + createdAt, + }; + // Keep file format stable even when refreshing. + writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8"); + } catch { + // Non-fatal — metadata write failure should not block project setup + } +} + +/** + * Read repo metadata from the external state directory. + * Returns null if the file doesn't exist or can't be parsed. + */ +export function readRepoMeta(externalPath: string): RepoMeta | null { + const metaPath = join(externalPath, "repo-meta.json"); + try { + if (!existsSync(metaPath)) return null; + const raw = readFileSync(metaPath, "utf-8"); + const parsed = JSON.parse(raw); + return isRepoMeta(parsed) ? parsed : null; + } catch { + return null; + } +} + +// ─── Inherited-Repo Detection ─────────────────────────────────────────────── + +/** + * Check whether `basePath` is inheriting a parent directory's git repo + * rather than being the git root itself. + * + * Returns true when ALL of: + * 1. basePath is inside a git repo (git rev-parse succeeds) + * 2. The resolved git root is a proper ancestor of basePath + * 3. There is no `.gsd` directory at the git root (the parent project + * has not been initialised with GSD) + * + * When true, the caller should run `git init` at basePath so that + * `repoIdentity()` produces a hash unique to this directory, preventing + * cross-project state leaks (#1639). + * + * When the git root already has `.gsd`, the directory is a legitimate + * subdirectory of an existing GSD project — `cd src/ && /gsd` should + * still load the parent project's milestones. + */ +export function isInheritedRepo(basePath: string): boolean { + try { + const root = resolveGitRoot(basePath); + const normalizedBase = canonicalizeExistingPath(basePath); + const normalizedRoot = canonicalizeExistingPath(root); + if (normalizedBase === normalizedRoot) return false; // basePath IS the root + + // The git root is a proper ancestor. Check whether it already has .gsd + // (i.e. the parent project was initialised with GSD). + if (existsSync(join(root, ".gsd"))) return false; + + // Also walk up from basePath to the git root checking for .gsd + let dir = normalizedBase; + while (dir !== normalizedRoot && dir !== dirname(dir)) { + if (existsSync(join(dir, ".gsd"))) return false; + dir = dirname(dir); + } + + return true; + } catch { + return false; + } +} + // ─── Repo Identity ────────────────────────────────────────────────────────── /** @@ -136,6 +261,15 @@ export function externalGsdRoot(basePath: string): string { return join(base, "projects", repoIdentity(basePath)); } +/** + * Resolve the root directory that stores project-scoped external state. + * Honors GSD_STATE_DIR override before falling back to GSD_HOME. + */ +export function externalProjectsRoot(): string { + const base = process.env.GSD_STATE_DIR || gsdHome; + return join(base, "projects"); +} + // ─── Symlink Management ───────────────────────────────────────────────────── /** @@ -153,9 +287,21 @@ export function ensureGsdSymlink(projectPath: string): string { const localGsd = join(projectPath, ".gsd"); const inWorktree = isInsideWorktree(projectPath); + // Guard: Never create a symlink at ~/.gsd — that's the user-level GSD home, + // not a project .gsd. This can happen if resolveProjectRoot() or + // escapeStaleWorktree() returned ~ as the project root (#1676). + const localGsdNormalized = localGsd.replaceAll("\\", "/"); + const gsdHomePath = gsdHome.replaceAll("\\", "/"); + if (localGsdNormalized === gsdHomePath) { + return localGsd; + } + // Ensure external directory exists mkdirSync(externalPath, { recursive: true }); + // Write repo metadata once so cleanup commands can identify this directory later. + writeRepoMeta(externalPath, getRemoteUrl(projectPath), resolveGitRoot(projectPath)); + const replaceWithSymlink = (): string => { rmSync(localGsd, { recursive: true, force: true }); symlinkSync(externalPath, localGsd, "junction"); diff --git a/src/resources/extensions/gsd/roadmap-mutations.ts b/src/resources/extensions/gsd/roadmap-mutations.ts index 85119c5b3..39521462b 100644 --- a/src/resources/extensions/gsd/roadmap-mutations.ts +++ b/src/resources/extensions/gsd/roadmap-mutations.ts @@ -27,11 +27,24 @@ export function markSliceDoneInRoadmap(basePath: string, mid: string, sid: strin return false; } - const updated = content.replace( + // Try checkbox format first: "- [ ] **S01: Title**" + let updated = content.replace( new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sid}:`, "m"), `$1[x] **${sid}:`, ); + // If checkbox format didn't match, try prose format: "## S01: Title" -> "## S01: \u2713 Title" + if (updated === content) { + updated = content.replace( + new RegExp(`^(#{1,4}\\s+(?:\\*{0,2})(?:Slice\\s+)?${sid}\\*{0,2}[:\\s.\\u2014\\u2013-]+\\s*)(.+)`, "m"), + (match, prefix, title) => { + // Already marked done — no-op + if (/^\u2713/.test(title) || /\(Complete\)\s*$/i.test(title)) return match; + return `${prefix}\u2713 ${title}`; + }, + ); + } + if (updated === content) return false; atomicWriteSync(roadmapFile, updated); @@ -93,3 +106,29 @@ export function markTaskDoneInPlan(basePath: string, planPath: string, tid: stri clearParseCache(); return true; } + +/** + * Mark a task as not done ([ ]) in the slice plan. + * Idempotent — no-op if already unchecked or if the task isn't found. + * + * @returns true if the plan was modified, false if no change was needed + */ +export function markTaskUndoneInPlan(basePath: string, planPath: string, tid: string): boolean { + let content: string; + try { + content = readFileSync(planPath, "utf-8"); + } catch { + return false; + } + + const updated = content.replace( + new RegExp(`^(\\s*-\\s+)\\[x\\]\\s+\\*\\*${tid}:`, "mi"), + `$1[ ] **${tid}:`, + ); + + if (updated === content) return false; + + atomicWriteSync(planPath, updated); + clearParseCache(); + return true; +} diff --git a/src/resources/extensions/gsd/roadmap-slices.ts b/src/resources/extensions/gsd/roadmap-slices.ts index 5b3b09fec..4c4cb4ceb 100644 --- a/src/resources/extensions/gsd/roadmap-slices.ts +++ b/src/resources/extensions/gsd/roadmap-slices.ts @@ -41,7 +41,8 @@ export function expandDependencies(deps: string[]): string[] { } function extractSlicesSection(content: string): string { - const headingMatch = /^## Slices\b.*$/m.exec(content); + // Match "## Slices", "## Slice Overview", "## Slice Table", etc. + const headingMatch = /^## Slice(?:s| Overview| Table| Summary| Status)\b.*$/m.exec(content); if (!headingMatch || headingMatch.index == null) return ""; const start = headingMatch.index + headingMatch[0].length; @@ -50,9 +51,92 @@ function extractSlicesSection(content: string): string { return (nextHeading ? rest.slice(0, nextHeading.index) : rest).trimEnd(); } +/** + * Parse markdown table format for slices. + * + * Handles LLM-generated table variants: + * | S01 | Title | High | [x] Done | + * | S01 | Title | High | Done | [x] | + * | S01 | Title | High | Complete | + * | S01 | Title | [x] | High | S01,S02 | + * + * Returns parsed slices if a table with slice IDs is found, otherwise empty array. + */ +function parseTableSlices(section: string): RoadmapSliceEntry[] { + const lines = section.split("\n"); + const slices: RoadmapSliceEntry[] = []; + + for (const line of lines) { + // Skip non-table lines, separator lines (|---|---|), and header rows + if (!line.includes("|")) continue; + if (/^\s*\|[\s:-]+\|/.test(line) && !/S\d+/.test(line)) continue; + + // Extract a slice ID from the row + const idMatch = line.match(/\b(S\d+)\b/); + if (!idMatch) continue; + + const id = idMatch[1]!; + const cells = line.split("|").map(c => c.trim()).filter(Boolean); + + // Determine completion status from any cell containing [x], "Done", or "Complete" + const fullRow = line.toLowerCase(); + const done = + /\[x\]/i.test(line) || + /\bdone\b/.test(fullRow) || + /\bcomplete(?:d)?\b/.test(fullRow); + + // Extract risk from any cell containing risk keywords + let risk: RiskLevel = "medium"; + for (const cell of cells) { + const cellLower = cell.toLowerCase(); + if (/\bhigh\b/.test(cellLower)) { risk = "high"; break; } + if (/\blow\b/.test(cellLower)) { risk = "low"; break; } + if (/\bmedium\b/.test(cellLower) || /\bmed\b/.test(cellLower)) { risk = "medium"; break; } + } + + // Extract dependencies from cells containing S-prefixed IDs (excluding the slice's own ID) + let depends: string[] = []; + for (const cell of cells) { + if (/depends|deps/i.test(cell) || (cell.match(/S\d+/g)?.length ?? 0) > 0) { + const depIds = (cell.match(/S\d+/g) ?? []).filter(d => d !== id); + if (depIds.length > 0 || /none|—|-/i.test(cell)) { + depends = expandDependencies(depIds); + break; + } + } + } + + // Extract title: use the cell after the ID cell, excluding cells that look like + // status, risk, dependency, or checkbox fields + let title = ""; + const idCellIndex = cells.findIndex(c => c.includes(id)); + for (let i = 0; i < cells.length; i++) { + if (i === idCellIndex) continue; + const cellLower = cells[i]!.toLowerCase(); + // Skip cells that are clearly metadata + if (/^\[[ x]\]/.test(cells[i]!) || /\[x\]/i.test(cells[i]!)) continue; + if (/^(high|medium|med|low)$/i.test(cells[i]!.trim())) continue; + if (/^(done|complete[d]?|pending|in.?progress|not started|todo)$/i.test(cells[i]!.trim())) continue; + if (/^(none|—|-)$/.test(cells[i]!.trim())) continue; + if (/^S\d+/.test(cells[i]!.trim()) && i !== idCellIndex) continue; + if (/depends|deps/i.test(cellLower)) continue; + // First remaining cell is likely the title + if (!title && cells[i]!.trim()) { + title = cells[i]!.trim().replace(/^\*+|\*+$/g, ""); + break; + } + } + + if (!title) title = id; + + slices.push({ id, title, risk, depends, done, demo: "" }); + } + + return slices; +} + export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { const slicesSection = extractSlicesSection(content); - const slices: RoadmapSliceEntry[] = []; if (!slicesSection) { // Fallback: detect prose-style slice headers (## Slice S01: Title) // when the LLM writes freeform prose instead of the ## Slices checklist. @@ -60,6 +144,15 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { return parseProseSliceHeaders(content); } + // Try table format first — if the section contains pipe-delimited rows with + // slice IDs, parse them as a table (#1736). + const tableSlices = parseTableSlices(slicesSection); + if (tableSlices.length > 0) { + return tableSlices; + } + + // Standard checkbox format + const slices: RoadmapSliceEntry[] = []; const checkboxItems = slicesSection.split("\n"); let currentSlice: RoadmapSliceEntry | null = null; @@ -91,6 +184,14 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { } if (currentSlice) slices.push(currentSlice); + + // When the ## Slices section exists but the checkbox parser found nothing + // (e.g. the LLM used H3 prose headers instead of checkboxes), fall through + // to the prose-header parser as a second-chance fallback. + if (slices.length === 0) { + return parseProseSliceHeaders(content); + } + return slices; } @@ -116,16 +217,37 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { */ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] { const slices: RoadmapSliceEntry[] = []; - // Match H1–H4 headers containing S with optional "Slice" prefix and bold markers. + // Match H1-H4 headers containing S with optional "Slice" prefix, bold markers, + // and optional checkmark completion marker before the slice ID. // Separator after the ID is flexible: colon, dash, em/en dash, dot, or just whitespace. - const headerPattern = /^#{1,4}\s+\*{0,2}(?:Slice\s+)?(S\d+)\*{0,2}[:\s.—–-]*\s*(.+)/gm; + const headerPattern = /^#{1,4}\s+\*{0,2}(?:\u2713\s+)?(?:Slice\s+)?(S\d+)\*{0,2}[:\s.\u2014\u2013-]*\s*(.+)/gm; let match: RegExpExecArray | null; + // Check for checkmark before the slice ID (e.g., "## checkmark S01: Title") + const prefixCheckPattern = /^#{1,4}\s+\*{0,2}\u2713\s+/; + while ((match = headerPattern.exec(content)) !== null) { const id = match[1]!; let title = match[2]!.trim().replace(/\*{1,2}$/g, "").trim(); // strip trailing bold markers if (!title) continue; // skip if we only matched the ID with no title + // Detect completion markers: + // 1. Checkmark before the slice ID: "## checkmark S01: Title" + // 2. Checkmark after separator: "## S01: checkmark Title" + // 3. (Complete) suffix: "## S01: Title (Complete)" + const line = match[0]; + let done = prefixCheckPattern.test(line); + + if (!done && title.startsWith("\u2713")) { + done = true; + title = title.replace(/^\u2713\s*/, ""); + } + + if (!done && /\(Complete\)\s*$/i.test(title)) { + done = true; + title = title.replace(/\s*\(Complete\)\s*$/i, ""); + } + // Try to extract depends from prose: "Depends on: S01" or "**Depends on:** S01, S02" const afterHeader = content.slice(match.index + match[0].length); const nextHeader = afterHeader.search(/^#{1,4}\s/m); @@ -142,7 +264,7 @@ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] { } } - slices.push({ id, title, risk: "medium" as RiskLevel, depends, done: false, demo: "" }); + slices.push({ id, title, risk: "medium" as RiskLevel, depends, done, demo: "" }); } return slices; diff --git a/src/resources/extensions/gsd/rule-registry.ts b/src/resources/extensions/gsd/rule-registry.ts new file mode 100644 index 000000000..6f818080f --- /dev/null +++ b/src/resources/extensions/gsd/rule-registry.ts @@ -0,0 +1,599 @@ +// GSD Extension — Unified Rule Registry +// +// Holds all dispatch rules and hooks as a flat list of UnifiedRule objects. +// Provides evaluation methods for each phase (dispatch, post-unit, pre-dispatch) +// and encapsulates mutable hook state as instance fields. +// +// A module-level singleton accessor allows existing code to migrate incrementally. + +import type { UnifiedRule, RulePhase } from "./rule-types.js"; +import type { DispatchAction, DispatchContext, DispatchRule } from "./auto-dispatch.js"; +import type { + PostUnitHookConfig, + PreDispatchHookConfig, + HookDispatchResult, + PreDispatchResult, + HookExecutionState, + PersistedHookState, + HookStatusEntry, +} from "./types.js"; +import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Artifact Path Resolution ────────────────────────────────────────────── + +export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string { + const parts = unitId.split("/"); + if (parts.length === 3) { + const [mid, sid, tid] = parts; + return join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks", `${tid}-${artifactName}`); + } + if (parts.length === 2) { + const [mid, sid] = parts; + return join(basePath, ".gsd", "milestones", mid, "slices", sid, artifactName); + } + return join(basePath, ".gsd", "milestones", parts[0], artifactName); +} + +// ─── Dispatch Rule Conversion ────────────────────────────────────────────── + +/** + * Convert an array of DispatchRule objects to UnifiedRule[] format. + * Preserves exact array order — dispatch is order-dependent (first-match-wins). + */ +export function convertDispatchRules(rules: DispatchRule[]): UnifiedRule[] { + return rules.map((rule) => ({ + name: rule.name, + when: "dispatch" as const, + evaluation: "first-match" as const, + where: rule.match, + then: (result: any) => result, + description: `Dispatch rule: ${rule.name}`, + })); +} + +// ─── RuleRegistry ───────────────────────────────────────────────────────── + +const HOOK_STATE_FILE = "hook-state.json"; + +export class RuleRegistry { + /** Static dispatch rules provided at construction time. */ + private readonly dispatchRules: UnifiedRule[]; + + // ── Mutable hook state (encapsulated, not module-level) ────────────── + + activeHook: HookExecutionState | null = null; + hookQueue: Array<{ + config: PostUnitHookConfig; + triggerUnitType: string; + triggerUnitId: string; + }> = []; + cycleCounts: Map = new Map(); + retryPending: boolean = false; + retryTrigger: { unitType: string; unitId: string; retryArtifact: string } | null = null; + + constructor(dispatchRules: UnifiedRule[]) { + this.dispatchRules = dispatchRules; + } + + // ── Core query ─────────────────────────────────────────────────────── + + /** + * Returns all rules: static dispatch rules + dynamically loaded hook rules. + * Hook rules are loaded fresh from preferences on each call (not cached). + */ + listRules(): UnifiedRule[] { + const rules: UnifiedRule[] = [...this.dispatchRules]; + + // Convert post-unit hooks to unified rules + const postHooks = resolvePostUnitHooks(); + for (const hook of postHooks) { + rules.push({ + name: hook.name, + when: "post-unit", + evaluation: "all-matching", + where: (unitType: string) => hook.after.includes(unitType), + then: () => hook, + description: `Post-unit hook: fires after ${hook.after.join(", ")}`, + lifecycle: { + artifact: hook.artifact, + retry_on: hook.retry_on, + max_cycles: hook.max_cycles, + }, + }); + } + + // Convert pre-dispatch hooks to unified rules + const preHooks = resolvePreDispatchHooks(); + for (const hook of preHooks) { + rules.push({ + name: hook.name, + when: "pre-dispatch", + evaluation: "all-matching", + where: (unitType: string) => hook.before.includes(unitType), + then: () => hook, + description: `Pre-dispatch hook: fires before ${hook.before.join(", ")}`, + }); + } + + return rules; + } + + // ── Dispatch evaluation (async, first-match-wins) ─────────────────── + + /** + * Iterate dispatch rules in order. First match wins. + * Returns stop action if no rule matches (unhandled phase). + */ + async evaluateDispatch(ctx: DispatchContext): Promise { + for (const rule of this.dispatchRules) { + const result = await rule.where(ctx); + if (result) { + if (result.action !== "skip") result.matchedRule = rule.name; + return result; + } + } + return { + action: "stop", + reason: `Unhandled phase "${ctx.state.phase}" — run /gsd doctor to diagnose.`, + level: "info", + matchedRule: "", + }; + } + + // ── Post-unit hook evaluation (sync, all-matching with lifecycle) ──── + + /** + * Replicate exact semantics of checkPostUnitHooks from post-unit-hooks.ts: + * hook-on-hook prevention, idempotency, cycle limits, retry_on, dequeue. + */ + evaluatePostUnit( + completedUnitType: string, + completedUnitId: string, + basePath: string, + ): HookDispatchResult | null { + // If we just completed a hook unit, handle its result + if (this.activeHook) { + return this._handleHookCompletion(basePath); + } + + // Don't trigger hooks for other hook units (prevent hook-on-hook chains) + // Don't trigger hooks for triage units or quick-task units + if ( + completedUnitType.startsWith("hook/") || + completedUnitType === "triage-captures" || + completedUnitType === "quick-task" + ) { + return null; + } + + // Check if any hooks are configured for this unit type + const hooks = resolvePostUnitHooks().filter(h => + h.after.includes(completedUnitType), + ); + if (hooks.length === 0) return null; + + // Build hook queue for this trigger + this.hookQueue = hooks.map(config => ({ + config, + triggerUnitType: completedUnitType, + triggerUnitId: completedUnitId, + })); + + return this._dequeueNextHook(basePath); + } + + private _dequeueNextHook(basePath: string): HookDispatchResult | null { + while (this.hookQueue.length > 0) { + const entry = this.hookQueue.shift()!; + const { config, triggerUnitType, triggerUnitId } = entry; + + // Check idempotency — if artifact already exists, skip + if (config.artifact) { + const artifactPath = resolveHookArtifactPath(basePath, triggerUnitId, config.artifact); + if (existsSync(artifactPath)) continue; + } + + // Check cycle limit + const cycleKey = `${config.name}/${triggerUnitType}/${triggerUnitId}`; + const currentCycle = (this.cycleCounts.get(cycleKey) ?? 0) + 1; + const maxCycles = config.max_cycles ?? 1; + if (currentCycle > maxCycles) continue; + + this.cycleCounts.set(cycleKey, currentCycle); + + this.activeHook = { + hookName: config.name, + triggerUnitType, + triggerUnitId, + cycle: currentCycle, + pendingRetry: false, + }; + + // Build prompt with variable substitution + const [mid, sid, tid] = triggerUnitId.split("/"); + let prompt = config.prompt + .replace(/\{milestoneId\}/g, mid ?? "") + .replace(/\{sliceId\}/g, sid ?? "") + .replace(/\{taskId\}/g, tid ?? ""); + + // Inject browser safety instruction + prompt += "\n\n**Browser tool safety:** Do NOT use `browser_wait_for` with `condition: \"network_idle\"` — it hangs indefinitely when dev servers keep persistent connections (Vite HMR, WebSocket). Use `selector_visible`, `text_visible`, or `delay` instead."; + + return { + hookName: config.name, + prompt, + model: config.model, + unitType: `hook/${config.name}`, + unitId: triggerUnitId, + }; + } + + // No more hooks — clear active state + this.activeHook = null; + return null; + } + + private _handleHookCompletion(basePath: string): HookDispatchResult | null { + const hook = this.activeHook!; + const hooks = resolvePostUnitHooks(); + const config = hooks.find(h => h.name === hook.hookName); + + // Check if retry was requested via retry_on artifact + if (config?.retry_on) { + const retryArtifactPath = resolveHookArtifactPath(basePath, hook.triggerUnitId, config.retry_on); + if (existsSync(retryArtifactPath)) { + const cycleKey = `${config.name}/${hook.triggerUnitType}/${hook.triggerUnitId}`; + const currentCycle = this.cycleCounts.get(cycleKey) ?? 1; + const maxCycles = config.max_cycles ?? 1; + + if (currentCycle < maxCycles) { + this.activeHook = null; + this.hookQueue = []; + this.retryPending = true; + this.retryTrigger = { + unitType: hook.triggerUnitType, + unitId: hook.triggerUnitId, + retryArtifact: config.retry_on, + }; + return null; + } + } + } + + // Hook completed normally — try next hook in queue + this.activeHook = null; + return this._dequeueNextHook(basePath); + } + + // ── Pre-dispatch hook evaluation (sync, all-matching with compose) ── + + /** + * Replicate exact semantics of runPreDispatchHooks from post-unit-hooks.ts: + * modify/skip/replace compose semantics. + */ + evaluatePreDispatch( + unitType: string, + unitId: string, + prompt: string, + basePath: string, + ): PreDispatchResult { + // Don't intercept hook units + if (unitType.startsWith("hook/")) { + return { action: "proceed", prompt, firedHooks: [] }; + } + + const hooks = resolvePreDispatchHooks().filter(h => + h.before.includes(unitType), + ); + if (hooks.length === 0) { + return { action: "proceed", prompt, firedHooks: [] }; + } + + const [mid, sid, tid] = unitId.split("/"); + const substitute = (text: string): string => + text + .replace(/\{milestoneId\}/g, mid ?? "") + .replace(/\{sliceId\}/g, sid ?? "") + .replace(/\{taskId\}/g, tid ?? ""); + + const firedHooks: string[] = []; + let currentPrompt = prompt; + + for (const hook of hooks) { + if (hook.action === "skip") { + if (hook.skip_if) { + const conditionPath = resolveHookArtifactPath(basePath, unitId, hook.skip_if); + if (!existsSync(conditionPath)) continue; + } + firedHooks.push(hook.name); + return { action: "skip", firedHooks }; + } + + if (hook.action === "replace") { + firedHooks.push(hook.name); + return { + action: "replace", + prompt: substitute(hook.prompt ?? ""), + unitType: hook.unit_type, + model: hook.model, + firedHooks, + }; + } + + if (hook.action === "modify") { + firedHooks.push(hook.name); + if (hook.prepend) { + currentPrompt = `${substitute(hook.prepend)}\n\n${currentPrompt}`; + } + if (hook.append) { + currentPrompt = `${currentPrompt}\n\n${substitute(hook.append)}`; + } + } + } + + return { + action: "proceed", + prompt: currentPrompt, + model: hooks.find(h => h.action === "modify" && h.model)?.model, + firedHooks, + }; + } + + // ── State accessors ───────────────────────────────────────────────── + + getActiveHook(): HookExecutionState | null { + return this.activeHook; + } + + isRetryPending(): boolean { + return this.retryPending; + } + + /** + * Returns the trigger unit info for a pending retry, or null. + * Clears the retry state after reading. + */ + consumeRetryTrigger(): { unitType: string; unitId: string; retryArtifact: string } | null { + if (!this.retryPending || !this.retryTrigger) return null; + const trigger = { ...this.retryTrigger }; + this.retryPending = false; + this.retryTrigger = null; + return trigger; + } + + /** Clear all mutable state (activeHook, hookQueue, cycleCounts, retryPending, retryTrigger). */ + resetState(): void { + this.activeHook = null; + this.hookQueue = []; + this.cycleCounts.clear(); + this.retryPending = false; + this.retryTrigger = null; + } + + // ── Persistence ───────────────────────────────────────────────────── + + private _hookStatePath(basePath: string): string { + return join(basePath, ".gsd", HOOK_STATE_FILE); + } + + /** Persist current hook cycle counts to disk. */ + persistState(basePath: string): void { + const state: PersistedHookState = { + cycleCounts: Object.fromEntries(this.cycleCounts), + savedAt: new Date().toISOString(), + }; + try { + const dir = join(basePath, ".gsd"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(this._hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8"); + } catch { + // Non-fatal — state is recreatable from artifacts + } + } + + /** Restore hook cycle counts from disk after a crash/restart. */ + restoreState(basePath: string): void { + try { + const filePath = this._hookStatePath(basePath); + if (!existsSync(filePath)) return; + const raw = readFileSync(filePath, "utf-8"); + const state: PersistedHookState = JSON.parse(raw); + if (state.cycleCounts && typeof state.cycleCounts === "object") { + this.cycleCounts.clear(); + for (const [key, value] of Object.entries(state.cycleCounts)) { + if (typeof value === "number") { + this.cycleCounts.set(key, value); + } + } + } + } catch { + // Non-fatal — fresh state is fine + } + } + + /** Clear persisted hook state file from disk. */ + clearPersistedState(basePath: string): void { + try { + const filePath = this._hookStatePath(basePath); + if (existsSync(filePath)) { + writeFileSync( + filePath, + JSON.stringify({ cycleCounts: {}, savedAt: new Date().toISOString() }, null, 2), + "utf-8", + ); + } + } catch { + // Non-fatal + } + } + + // ── Hook status reporting ─────────────────────────────────────────── + + /** Get status of all configured hooks for display. */ + getHookStatus(): HookStatusEntry[] { + const entries: HookStatusEntry[] = []; + + const postHooks = resolvePostUnitHooks(); + for (const hook of postHooks) { + const activeCycles: Record = {}; + for (const [key, count] of this.cycleCounts) { + if (key.startsWith(`${hook.name}/`)) { + activeCycles[key] = count; + } + } + entries.push({ + name: hook.name, + type: "post", + enabled: hook.enabled !== false, + targets: hook.after, + activeCycles, + }); + } + + const preHooks = resolvePreDispatchHooks(); + for (const hook of preHooks) { + entries.push({ + name: hook.name, + type: "pre", + enabled: hook.enabled !== false, + targets: hook.before, + activeCycles: {}, + }); + } + + return entries; + } + + /** + * Manually trigger a specific hook for a unit. + * Bypasses normal flow — forces hook to run even if artifact exists. + */ + triggerHookManually( + hookName: string, + unitType: string, + unitId: string, + basePath: string, + ): HookDispatchResult | null { + const hook = resolvePostUnitHooks().find(h => h.name === hookName); + if (!hook) { + console.error(`[triggerHookManually] Hook "${hookName}" not found in post_unit_hooks`); + return null; + } + + if (!hook.prompt || typeof hook.prompt !== "string" || hook.prompt.trim().length === 0) { + console.error(`[triggerHookManually] Hook "${hookName}" has empty prompt`); + return null; + } + + this.activeHook = { + hookName: hook.name, + triggerUnitType: unitType, + triggerUnitId: unitId, + cycle: 1, + pendingRetry: false, + }; + + this.hookQueue = [{ + config: hook, + triggerUnitType: unitType, + triggerUnitId: unitId, + }]; + + const cycleKey = `${hook.name}/${unitType}/${unitId}`; + const currentCycle = (this.cycleCounts.get(cycleKey) ?? 0) + 1; + this.cycleCounts.set(cycleKey, currentCycle); + this.activeHook.cycle = currentCycle; + + const [mid, sid, tid] = unitId.split("/"); + const prompt = hook.prompt + .replace(/\{milestoneId\}/g, mid ?? "") + .replace(/\{sliceId\}/g, sid ?? "") + .replace(/\{taskId\}/g, tid ?? ""); + + return { + hookName: hook.name, + prompt, + model: hook.model, + unitType: `hook/${hook.name}`, + unitId, + }; + } + + /** Format hook status for terminal display. */ + formatHookStatus(): string { + const entries = this.getHookStatus(); + if (entries.length === 0) { + return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .gsd/preferences.md"; + } + + const lines: string[] = ["Configured Hooks:", ""]; + + const postHooks = entries.filter(e => e.type === "post"); + const preHooks = entries.filter(e => e.type === "pre"); + + if (postHooks.length > 0) { + lines.push("Post-Unit Hooks (run after unit completes):"); + for (const hook of postHooks) { + const status = hook.enabled ? "enabled" : "disabled"; + const cycles = Object.keys(hook.activeCycles).length; + const cycleInfo = cycles > 0 ? ` (${cycles} active cycle${cycles === 1 ? "" : "s"})` : ""; + lines.push(` ${hook.name} [${status}] → after: ${hook.targets.join(", ")}${cycleInfo}`); + } + lines.push(""); + } + + if (preHooks.length > 0) { + lines.push("Pre-Dispatch Hooks (run before unit dispatches):"); + for (const hook of preHooks) { + const status = hook.enabled ? "enabled" : "disabled"; + lines.push(` ${hook.name} [${status}] → before: ${hook.targets.join(", ")}`); + } + lines.push(""); + } + + return lines.join("\n"); + } +} + +// ─── Module-level Singleton ───────────────────────────────────────────────── + +let _registry: RuleRegistry | null = null; + +/** Get the singleton registry. Throws if not initialized. */ +export function getRegistry(): RuleRegistry { + if (!_registry) { + throw new Error("RuleRegistry not initialized — call initRegistry() or setRegistry() first."); + } + return _registry; +} + +/** Set the singleton registry instance. */ +export function setRegistry(r: RuleRegistry): void { + _registry = r; +} + +/** Create and set the singleton registry with the given dispatch rules. */ +export function initRegistry(dispatchRules: UnifiedRule[]): RuleRegistry { + const registry = new RuleRegistry(dispatchRules); + setRegistry(registry); + return registry; +} + +/** + * Get the singleton registry, lazily creating one with empty dispatch rules + * if not yet initialized. This ensures facade functions work even when + * the full registry hasn't been set up (e.g. during testing). + */ +export function getOrCreateRegistry(): RuleRegistry { + if (!_registry) { + _registry = new RuleRegistry([]); + } + return _registry; +} + +/** Reset the singleton (for testing). */ +export function resetRegistry(): void { + _registry = null; +} diff --git a/src/resources/extensions/gsd/rule-types.ts b/src/resources/extensions/gsd/rule-types.ts new file mode 100644 index 000000000..37478053c --- /dev/null +++ b/src/resources/extensions/gsd/rule-types.ts @@ -0,0 +1,68 @@ +// GSD Extension — Unified Rule Type Definitions +// +// Every dispatch rule and hook is expressed as a `UnifiedRule` with a +// consistent when/where/then shape. This file defines the type system; +// the `RuleRegistry` class in rule-registry.ts holds instances at runtime. + +import type { DispatchAction, DispatchContext } from "./auto-dispatch.js"; +import type { + PostUnitHookConfig, + PreDispatchHookConfig, + HookDispatchResult, + PreDispatchResult, + HookExecutionState, + HookStatusEntry, +} from "./types.js"; + +// ─── Phase & Evaluation Strategy ──────────────────────────────────────────── + +/** Which phase/event a rule responds to. */ +export type RulePhase = "dispatch" | "post-unit" | "pre-dispatch"; + +/** How a rule is evaluated relative to peers in the same phase. */ +export type RuleEvaluation = "first-match" | "all-matching"; + +// ─── Lifecycle Metadata (hooks only) ──────────────────────────────────────── + +/** Optional lifecycle metadata attached to hook-derived rules. */ +export interface RuleLifecycle { + /** Expected output file name (relative to unit dir). Used for idempotency. */ + artifact?: string; + /** If this file is produced instead of artifact, re-run the trigger unit. */ + retry_on?: string; + /** Max times this hook can fire for the same trigger unit. */ + max_cycles?: number; + /** Idempotency key pattern for this hook. */ + idempotency_key?: string; +} + +// ─── Unified Rule ─────────────────────────────────────────────────────────── + +/** + * A single entry in the rule registry. Dispatch rules, post-unit hooks, + * and pre-dispatch hooks all share this shape. + */ +export interface UnifiedRule { + /** Stable human-readable identifier (existing names preserved per D005). */ + name: string; + /** Which phase/event this rule responds to. */ + when: RulePhase; + /** How this rule is evaluated relative to peers. */ + evaluation: RuleEvaluation; + /** + * Predicate/match function. + * - Dispatch rules: async, receives DispatchContext, returns DispatchAction | null. + * - Post-unit hooks: sync, receives (unitType, unitId, basePath). + * - Pre-dispatch hooks: sync, receives (unitType, unitId, prompt, basePath). + */ + where: (...args: any[]) => Promise | any; + /** + * Action builder. May be merged with `where` for dispatch rules where + * the match function returns the action directly. + */ + then: (...args: any[]) => any; + /** Optional human-readable summary for LLM inspection. */ + description?: string; + /** Optional hook lifecycle metadata. */ + lifecycle?: RuleLifecycle; +} diff --git a/src/resources/extensions/gsd/service-tier.ts b/src/resources/extensions/gsd/service-tier.ts new file mode 100644 index 000000000..7e2f4613a --- /dev/null +++ b/src/resources/extensions/gsd/service-tier.ts @@ -0,0 +1,171 @@ +/** + * Service Tier — gating, status formatting, icon resolution, and + * the /gsd fast command handler. + * + * Service tiers (priority/flex) are an OpenAI feature that only applies + * to gpt-5.4 variants. This module centralizes the model-gating logic + * so that icons, preferences, and the before_provider_request hook all + * use a single source of truth. + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { existsSync, readFileSync } from "node:fs"; +import { saveFile } from "./files.js"; +import { + getGlobalGSDPreferencesPath, + loadEffectiveGSDPreferences, + loadGlobalGSDPreferences, +} from "./preferences.js"; +import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ServiceTierSetting = "priority" | "flex" | undefined; + +// ─── Gating ────────────────────────────────────────────────────────────────── + +/** + * Returns true when the given model ID supports OpenAI service tiers. + * Currently only gpt-5.4 variants qualify. + */ +export function supportsServiceTier(modelId: string): boolean { + if (!modelId) return false; + // Strip provider prefix if present (e.g. "openai/gpt-5.4" → "gpt-5.4") + const bare = modelId.includes("/") ? modelId.split("/").pop()! : modelId; + return bare.startsWith("gpt-5.4"); +} + +// ─── Status Formatting ─────────────────────────────────────────────────────── + +/** + * Human-readable description of the current service tier setting. + */ +export function formatServiceTierStatus(tier: ServiceTierSetting): string { + if (!tier) { + return [ + "Service tier: disabled", + "", + "Usage:", + " /gsd fast on Set to priority (2x cost, faster)", + " /gsd fast flex Set to flex (0.5x cost, slower)", + " /gsd fast off Disable service tier", + "", + "Only affects gpt-5.4 models.", + ].join("\n"); + } + + const label = tier === "priority" ? "priority (2x cost, faster)" : "flex (0.5x cost, slower)"; + return [ + `Service tier: ${label}`, + "", + "Usage:", + " /gsd fast on Set to priority (2x cost, faster)", + " /gsd fast flex Set to flex (0.5x cost, slower)", + " /gsd fast off Disable service tier", + "", + "Only affects gpt-5.4 models.", + ].join("\n"); +} + +// ─── Icon Resolution ───────────────────────────────────────────────────────── + +/** + * Returns the appropriate icon for the active service tier and model. + * Returns empty string when the tier is inactive or the model doesn't + * support service tiers. + */ +export function resolveServiceTierIcon(tier: ServiceTierSetting, modelId: string): string { + if (!tier || !supportsServiceTier(modelId)) return ""; + return tier === "priority" ? "⚡" : "💰"; +} + +// ─── Preference Read ───────────────────────────────────────────────────────── + +/** + * Read the effective service_tier setting from preferences. + */ +export function getEffectiveServiceTier(): ServiceTierSetting { + const prefs = loadEffectiveGSDPreferences()?.preferences; + const raw = prefs?.service_tier; + if (raw === "priority" || raw === "flex") return raw; + return undefined; +} + +// ─── Preference Write ──────────────────────────────────────────────────────── + +function extractBodyAfterFrontmatter(content: string): string | null { + const start = content.startsWith("---\n") ? 4 : content.startsWith("---\r\n") ? 5 : -1; + if (start === -1) return null; + const closingIdx = content.indexOf("\n---", start); + if (closingIdx === -1) return null; + const after = content.slice(closingIdx + 4); + return after.trim() ? after : null; +} + +async function writeGlobalServiceTier( + ctx: ExtensionCommandContext, + tier: ServiceTierSetting, +): Promise { + const path = getGlobalGSDPreferencesPath(); + await ensurePreferencesFile(path, ctx, "global"); + + const existing = loadGlobalGSDPreferences(); + const prefs: Record = existing?.preferences ? { ...existing.preferences } : {}; + prefs.version = prefs.version || 1; + + if (tier) { + prefs.service_tier = tier; + } else { + delete prefs.service_tier; + } + + const frontmatter = serializePreferencesToFrontmatter(prefs); + let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; + if (existsSync(path)) { + const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); + if (preserved) body = preserved; + } + + await saveFile(path, `---\n${frontmatter}---${body}`); + await ctx.waitForIdle(); + await ctx.reload(); +} + +// ─── Command Handler ───────────────────────────────────────────────────────── + +/** + * Handle `/gsd fast [on|off|flex|status]`. + */ +export async function handleFast(args: string, ctx: ExtensionCommandContext): Promise { + const trimmed = args.trim().toLowerCase(); + + if (!trimmed || trimmed === "status") { + const tier = getEffectiveServiceTier(); + ctx.ui.notify(formatServiceTierStatus(tier), "info"); + return; + } + + if (trimmed === "on") { + await writeGlobalServiceTier(ctx, "priority"); + ctx.ui.notify("Service tier set to priority (2x cost, faster responses). Only affects gpt-5.4 models.", "info"); + return; + } + + if (trimmed === "off") { + await writeGlobalServiceTier(ctx, undefined); + ctx.ui.notify("Service tier disabled.", "info"); + return; + } + + if (trimmed === "flex") { + await writeGlobalServiceTier(ctx, "flex"); + ctx.ui.notify("Service tier set to flex (0.5x cost, slower responses). Only affects gpt-5.4 models.", "info"); + return; + } + + ctx.ui.notify( + "Usage: /gsd fast [on|off|flex|status]\n\n on Priority tier (2x cost, faster)\n off Disable service tier\n flex Flex tier (0.5x cost, slower)\n status Show current setting", + "warning", + ); +} diff --git a/src/resources/extensions/gsd/session-lock.ts b/src/resources/extensions/gsd/session-lock.ts index b2b722388..eb9ea9fcc 100644 --- a/src/resources/extensions/gsd/session-lock.ts +++ b/src/resources/extensions/gsd/session-lock.ts @@ -70,6 +70,10 @@ let _lockCompromised: boolean = false; /** Whether we've already registered a process.on('exit') handler. */ let _exitHandlerRegistered: boolean = false; +/** Registry of all gsdDir paths where locks were created during this session. + * The exit handler cleans ALL of these, not just the current gsdRoot(). (#1578) */ +const _lockDirRegistry: Set = new Set(); + /** Snapshotted lock file path — captured at acquireSessionLock time to avoid * gsdRoot() resolving differently in worktree vs project root contexts (#1363). */ let _snapshotLockPath: string | null = null; @@ -137,7 +141,10 @@ export function cleanupStrayLockFiles(basePath: string): void { * Uses module-level references so it always operates on current state. * Only registers once — subsequent calls are no-ops. */ -function ensureExitHandler(gsdDir: string): void { +function ensureExitHandler(_gsdDir: string): void { + // Register the gsdDir so exit cleanup covers it + _lockDirRegistry.add(_gsdDir); + if (_exitHandlerRegistered) return; _exitHandlerRegistered = true; @@ -145,16 +152,19 @@ function ensureExitHandler(gsdDir: string): void { try { if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; } } catch { /* best-effort */ } - // Remove the auto.lock metadata file so crash-recovery doesn't - // falsely detect an interrupted session on the next startup. - try { - const lockFile = join(gsdDir, LOCK_FILE); - if (existsSync(lockFile)) unlinkSync(lockFile); - } catch { /* best-effort */ } - try { - const lockDir = join(gsdDir + ".lock"); - if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true }); - } catch { /* best-effort */ } + // Clean ALL registered lock paths, not just the current one (#1578). + // Lock files accumulate across main project .gsd/, worktree .gsd/, + // and projects registry paths — cleanup must cover all of them. + for (const dir of _lockDirRegistry) { + try { + const lockFile = join(dir, LOCK_FILE); + if (existsSync(lockFile)) unlinkSync(lockFile); + } catch { /* best-effort */ } + try { + const lockDir = join(dir + ".lock"); + if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true }); + } catch { /* best-effort */ } + } }); } @@ -233,7 +243,17 @@ export function acquireSessionLock(basePath: string): SessionLockResult { ); return; // Suppress false positive } - // Past the stale window — this is a real compromise + // Past the stale window — check if the lock file still belongs to us before + // declaring compromise (#1578). If our PID still owns the metadata, this is + // a false positive from a very long event loop stall (e.g. subagent execution). + const existing = readExistingLockData(lp); + if (existing && existing.pid === process.pid) { + process.stderr.write( + `[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`, + ); + return; // Our PID still owns the lock file — no real takeover + } + // Lock file is gone or owned by another PID — real compromise _lockCompromised = true; _releaseFunction = null; }, @@ -283,6 +303,14 @@ export function acquireSessionLock(basePath: string): SessionLockResult { ); return; } + // Check PID ownership before declaring compromise (#1578) + const existing = readExistingLockData(lp); + if (existing && existing.pid === process.pid) { + process.stderr.write( + `[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`, + ); + return; + } _lockCompromised = true; _releaseFunction = null; }, @@ -459,7 +487,7 @@ export function releaseSessionLock(basePath: string): void { _releaseFunction = null; } - // Remove the lock file + // Remove the lock file at the current path const lp = lockPath(basePath); try { if (existsSync(lp)) unlinkSync(lp); @@ -467,10 +495,7 @@ export function releaseSessionLock(basePath: string): void { // Non-fatal } - // Remove the proper-lockfile directory (.gsd.lock/) if it exists. - // proper-lockfile creates this directory as the OS-level lock mechanism. - // If the process exits without calling _releaseFunction (SIGKILL, crash), - // this directory is stranded and blocks the next session (#1245). + // Remove the proper-lockfile directory (.gsd.lock/) for the current path try { const lockDir = join(gsdRoot(basePath) + ".lock"); if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true }); @@ -478,6 +503,20 @@ export function releaseSessionLock(basePath: string): void { // Non-fatal } + // Clean ALL registered lock paths (#1578) — lock files accumulate across + // main project .gsd/, worktree .gsd/, and projects registry paths. + for (const dir of _lockDirRegistry) { + try { + const lockFile = join(dir, LOCK_FILE); + if (existsSync(lockFile)) unlinkSync(lockFile); + } catch { /* best-effort */ } + try { + const lockDir = join(dir + ".lock"); + if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true }); + } catch { /* best-effort */ } + } + _lockDirRegistry.clear(); + // Clean up numbered lock file variants from cloud sync conflicts (#1315) cleanupStrayLockFiles(basePath); @@ -510,6 +549,14 @@ export function isSessionLockHeld(basePath: string): boolean { return _lockedPath === basePath && _lockPid === process.pid; } +/** + * Returns a snapshot of the registered lock directory paths for diagnostics. + * Exported for tests only. + */ +export function _getRegisteredLockDirs(): string[] { + return [..._lockDirRegistry]; +} + // ─── Internal Helpers ─────────────────────────────────────────────────────── function readExistingLockData(lp: string): SessionLockData | null { diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 58451ca1a..285c4a898 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -38,6 +38,20 @@ import { join, resolve } from 'path'; import { existsSync, readdirSync } from 'node:fs'; import { debugCount, debugTime } from './debug-logger.js'; +/** + * A "ghost" milestone directory contains only META.json (and no substantive + * files like CONTEXT, CONTEXT-DRAFT, ROADMAP, or SUMMARY). These appear when + * a milestone is created but never initialised. Treating them as active causes + * auto-mode to stall or falsely declare completion. + */ +export function isGhostMilestone(basePath: string, mid: string): boolean { + const context = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draft = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const roadmap = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const summary = resolveMilestoneFile(basePath, mid, "SUMMARY"); + return !context && !draft && !roadmap && !summary; +} + // ─── Query Functions ─────────────────────────────────────────────────────── /** @@ -121,6 +135,7 @@ export async function getActiveMilestoneId(basePath: string): Promise { return result; } +/** + * Extract milestone title from CONTEXT.md or CONTEXT-DRAFT.md heading. + * Falls back to the provided fallback (usually the milestone ID). + */ +function extractContextTitle(content: string | null, fallback: string): string { + if (!content) return fallback; + const h1 = content.split('\n').find(line => line.startsWith('# ')); + if (!h1) return fallback; + // Extract title from "# M005: Platform Foundation & Separation" format + return h1.slice(2).trim().replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, '') || fallback; +} + async function _deriveStateImpl(basePath: string): Promise { const milestoneIds = findMilestoneIds(basePath); @@ -306,32 +333,43 @@ async function _deriveStateImpl(basePath: string): Promise { completeMilestoneIds.add(mid); continue; } + // Ghost milestone (only META.json, no CONTEXT/ROADMAP/SUMMARY) — skip entirely + if (isGhostMilestone(basePath, mid)) continue; + // No roadmap and no summary — treat as incomplete/active if (!activeMilestoneFound) { // Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones. // A draft seed means the milestone has discussion material but no full context yet. const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - if (!contextFile) { - const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); - if (draftFile) activeMilestoneHasDraft = true; - } + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + if (!contextFile && draftFile) activeMilestoneHasDraft = true; + + // Extract title from CONTEXT.md or CONTEXT-DRAFT.md heading before falling back to mid. + const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; + const draftContent = draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const title = extractContextTitle(contextContent || draftContent, mid); // Check milestone-level dependencies before promoting to active. // Without this, a queued milestone with depends_on in its CONTEXT - // frontmatter would be promoted to active even when its deps are unmet - // (the dep check only existed in the has-roadmap path previously). - const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; - const deps = parseContextDependsOn(contextContent); + // or CONTEXT-DRAFT frontmatter would be promoted to active even when + // its deps are unmet. Fall back to CONTEXT-DRAFT.md when absent (#1724). + const deps = parseContextDependsOn(contextContent ?? draftContent); const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); if (depsUnmet) { - registry.push({ id: mid, title: mid, status: 'pending', dependsOn: deps }); + registry.push({ id: mid, title, status: 'pending', dependsOn: deps }); } else { - activeMilestone = { id: mid, title: mid }; + activeMilestone = { id: mid, title }; activeMilestoneFound = true; - registry.push({ id: mid, title: mid, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) }); + registry.push({ id: mid, title, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) }); } } else { - registry.push({ id: mid, title: mid, status: 'pending' }); + // For milestones after the active one, also try to extract title from context files. + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; + const draftContent = draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const title = extractContextTitle(contextContent || draftContent, mid); + registry.push({ id: mid, title, status: 'pending' }); } continue; } @@ -375,10 +413,13 @@ async function _deriveStateImpl(basePath: string): Promise { if (summaryFile) { registry.push({ id: mid, title, status: 'complete' }); } else if (!activeMilestoneFound) { - // Check milestone-level dependencies before promoting to active + // Check milestone-level dependencies before promoting to active. + // Fall back to CONTEXT-DRAFT.md when CONTEXT.md is absent (#1724). const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; - const deps = parseContextDependsOn(contextContent); + const draftContent = draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const deps = parseContextDependsOn(contextContent ?? draftContent); const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); if (depsUnmet) { registry.push({ id: mid, title, status: 'pending', dependsOn: deps }); @@ -391,8 +432,11 @@ async function _deriveStateImpl(basePath: string): Promise { } } else { const contextFile2 = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const contextContent2 = contextFile2 ? await cachedLoadFile(contextFile2) : null; - const deps2 = parseContextDependsOn(contextContent2); + const draftFileForDeps3 = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const contextOrDraftContent3 = contextFile2 + ? await cachedLoadFile(contextFile2) + : (draftFileForDeps3 ? await cachedLoadFile(draftFileForDeps3) : null); + const deps2 = parseContextDependsOn(contextOrDraftContent3); registry.push({ id: mid, title, status: 'pending', ...(deps2.length > 0 ? { dependsOn: deps2 } : {}) }); } } @@ -447,8 +491,29 @@ async function _deriveStateImpl(basePath: string): Promise { }, }; } + // All real milestones were ghosts (empty registry) → treat as pre-planning + if (registry.length === 0) { + return { + activeMilestone: null, + activeSlice: null, + activeTask: null, + phase: 'pre-planning', + recentDecisions: [], + blockers: [], + nextAction: 'No milestones found. Run /gsd to create one.', + registry: [], + requirements, + progress: { + milestones: { done: 0, total: 0 }, + }, + }; + } // All milestones complete const lastEntry = registry[registry.length - 1]; + const activeReqs = requirements.active ?? 0; + const completionNote = activeReqs > 0 + ? `All milestones complete. ${activeReqs} active requirement${activeReqs === 1 ? '' : 's'} in REQUIREMENTS.md ${activeReqs === 1 ? 'has' : 'have'} not been mapped to a milestone.` + : 'All milestones complete.'; return { activeMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null, activeSlice: null, @@ -456,7 +521,7 @@ async function _deriveStateImpl(basePath: string): Promise { phase: 'complete', recentDecisions: [], blockers: [], - nextAction: 'All milestones complete.', + nextAction: completionNote, registry, requirements, progress: { @@ -489,6 +554,30 @@ async function _deriveStateImpl(basePath: string): Promise { }; } + // ── Zero-slice roadmap guard (#1785) ───────────────────────────────── + // A stub roadmap (placeholder text, no slice definitions) has a truthy + // roadmap object but an empty slices array. Without this check the + // slice-finding loop below finds nothing and returns phase: "blocked". + // An empty slices array means the roadmap still needs slice definitions, + // so the correct phase is pre-planning. + if (activeRoadmap.slices.length === 0) { + return { + activeMilestone, + activeSlice: null, + activeTask: null, + phase: 'pre-planning', + recentDecisions: [], + blockers: [], + nextAction: `Milestone ${activeMilestone.id} has a roadmap but no slices defined. Add slices to the roadmap.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: { done: 0, total: 0 }, + }, + }; + } + // Check if active milestone needs validation or completion (all slices done) if (isMilestoneComplete(activeRoadmap)) { const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION"); @@ -718,6 +807,39 @@ async function _deriveStateImpl(basePath: string): Promise { // REPLAN.md exists — loop protection: fall through to normal executing } + // ── REPLAN-TRIGGER detection: triage-initiated replan ────────────────── + // Manual `/gsd triage` writes REPLAN-TRIGGER.md when a capture is classified + // as "replan". Detect it here and transition to replanning-slice so the + // dispatch loop picks it up (instead of silently advancing past it). + if (!blockerTaskId) { + const replanTriggerFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER"); + if (replanTriggerFile) { + // Same loop protection: if REPLAN.md already exists, a replan was + // already performed — skip further replanning and continue executing. + const replanFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN"); + if (!replanFile) { + return { + activeMilestone, + activeSlice, + activeTask, + phase: 'replanning-slice', + recentDecisions: [], + blockers: ['Triage replan trigger detected — slice replan required'], + nextAction: `Triage replan triggered for slice ${activeSlice.id}. Replan before continuing.`, + + activeWorkspace: undefined, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + } + } + // Check for interrupted work const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id); const continueFile = sDir ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") : null; diff --git a/src/resources/extensions/gsd/structured-data-formatter.ts b/src/resources/extensions/gsd/structured-data-formatter.ts index 20c3768eb..e8c6bf51c 100644 --- a/src/resources/extensions/gsd/structured-data-formatter.ts +++ b/src/resources/extensions/gsd/structured-data-formatter.ts @@ -25,6 +25,7 @@ interface DecisionInput { choice: string; rationale: string; revisable: string; + made_by?: string; } interface RequirementInput { @@ -61,6 +62,7 @@ export function formatDecisionCompact(decision: DecisionInput): string { decision.choice, decision.rationale, decision.revisable, + decision.made_by ?? 'agent', ].join(" | "); } @@ -70,7 +72,7 @@ export function formatDecisionsCompact(decisions: DecisionInput[]): string { return "# Decisions (compact)\n(none)"; } - const header = "# Decisions (compact)\nFields: id | when | scope | decision | choice | rationale | revisable"; + const header = "# Decisions (compact)\nFields: id | when | scope | decision | choice | rationale | revisable | made_by"; const lines = decisions.map(formatDecisionCompact); return `${header}\n\n${lines.join("\n")}`; } diff --git a/src/resources/extensions/gsd/templates/decisions.md b/src/resources/extensions/gsd/templates/decisions.md index d8e56d1ee..f8f44ee7c 100644 --- a/src/resources/extensions/gsd/templates/decisions.md +++ b/src/resources/extensions/gsd/templates/decisions.md @@ -4,5 +4,5 @@ To reverse a decision, add a new row that supersedes it. Read this file at the start of any planning or research phase. --> -| # | When | Scope | Decision | Choice | Rationale | Revisable? | -|---|------|-------|----------|--------|-----------|------------| +| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By | +|---|------|-------|----------|--------|-----------|------------|---------| diff --git a/src/resources/extensions/gsd/tests/agent-end-retry.test.ts b/src/resources/extensions/gsd/tests/agent-end-retry.test.ts index 2ce2e5fd0..6db2f9d36 100644 --- a/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +++ b/src/resources/extensions/gsd/tests/agent-end-retry.test.ts @@ -14,30 +14,30 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const AUTO_TS_PATH = join(__dirname, "..", "auto.ts"); -const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto-loop.ts"); +const AUTO_RESOLVE_TS_PATH = join(__dirname, "..", "auto", "resolve.ts"); const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts"); function getAutoTsSource(): string { return readFileSync(AUTO_TS_PATH, "utf-8"); } -function getAutoLoopTsSource(): string { - return readFileSync(AUTO_LOOP_TS_PATH, "utf-8"); +function getAutoResolveTsSource(): string { + return readFileSync(AUTO_RESOLVE_TS_PATH, "utf-8"); } function getSessionTsSource(): string { return readFileSync(SESSION_TS_PATH, "utf-8"); } -test("auto-loop.ts declares _currentResolve for per-unit one-shot promises", () => { - const source = getAutoLoopTsSource(); +test("auto/resolve.ts declares _currentResolve for per-unit one-shot promises", () => { + const source = getAutoResolveTsSource(); assert.ok( source.includes("_currentResolve"), - "auto-loop.ts must declare _currentResolve for the per-unit resolve function", + "auto/resolve.ts must declare _currentResolve for the per-unit resolve function", ); assert.ok( source.includes("_sessionSwitchInFlight"), - "auto-loop.ts must declare _sessionSwitchInFlight guard", + "auto/resolve.ts must declare _sessionSwitchInFlight guard", ); }); @@ -81,3 +81,63 @@ test("handleAgentEnd is a thin compatibility wrapper", () => { "handleAgentEnd must not dispatch recursively", ); }); + +test("handleAgentEnd early return calls resolveAgentEndCancelled", () => { + const source = getAutoTsSource(); + const fnIdx = source.indexOf("export async function handleAgentEnd"); + assert.ok(fnIdx > -1, "handleAgentEnd must exist in auto.ts"); + const fnBlock = source.slice(fnIdx, source.indexOf("\n// ─── ", fnIdx + 100)); + + assert.ok( + fnBlock.includes("resolveAgentEndCancelled()"), + "handleAgentEnd must call resolveAgentEndCancelled on early return to prevent orphaned promises", + ); +}); + +test("pauseAuto calls resolveAgentEndCancelled to unblock the loop", () => { + const source = getAutoTsSource(); + const fnIdx = source.indexOf("export async function pauseAuto"); + assert.ok(fnIdx > -1, "pauseAuto must exist in auto.ts"); + // Extract the function body (up to the next export or top-level function) + const fnBlock = source.slice(fnIdx, source.indexOf("\n/**\n * Build", fnIdx + 100)); + + assert.ok( + fnBlock.includes("resolveAgentEndCancelled()"), + "pauseAuto must call resolveAgentEndCancelled to unblock the auto-loop promise", + ); +}); + +test("auto-timers.ts idle watchdog catch calls resolveAgentEndCancelled", () => { + const TIMERS_PATH = join(__dirname, "..", "auto-timers.ts"); + const source = readFileSync(TIMERS_PATH, "utf-8"); + + const idleCatchIdx = source.indexOf("[idle-watchdog] Unhandled error"); + assert.ok(idleCatchIdx > -1, "idle watchdog catch block must exist"); + // Check that resolveAgentEndCancelled is called near this catch + const catchRegion = source.slice(Math.max(0, idleCatchIdx - 200), idleCatchIdx + 200); + assert.ok( + catchRegion.includes("resolveAgentEndCancelled()"), + "idle watchdog catch block must call resolveAgentEndCancelled", + ); +}); + +test("auto-timers.ts hard timeout catch calls resolveAgentEndCancelled", () => { + const TIMERS_PATH = join(__dirname, "..", "auto-timers.ts"); + const source = readFileSync(TIMERS_PATH, "utf-8"); + + const hardCatchIdx = source.indexOf("[hard-timeout] Unhandled error"); + assert.ok(hardCatchIdx > -1, "hard timeout catch block must exist"); + const catchRegion = source.slice(Math.max(0, hardCatchIdx - 200), hardCatchIdx + 200); + assert.ok( + catchRegion.includes("resolveAgentEndCancelled()"), + "hard timeout catch block must call resolveAgentEndCancelled", + ); +}); + +test("resolveAgentEndCancelled is exported from auto/resolve.ts", () => { + const source = getAutoResolveTsSource(); + assert.ok( + source.includes("export function resolveAgentEndCancelled"), + "auto/resolve.ts must export resolveAgentEndCancelled", + ); +}); diff --git a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts index ff8c393f2..58cc118e0 100644 --- a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +++ b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts @@ -78,7 +78,7 @@ function createMilestoneArtifacts(dir: string, mid: string): void { // ─── Source-level: verify the merge code exists in the "all complete" path ──── test("auto-loop 'all milestones complete' path merges before stopping (#962)", () => { - const loopSrc = readFileSync(join(__dirname, "..", "auto-loop.ts"), "utf-8"); + const loopSrc = readFileSync(join(__dirname, "..", "auto", "phases.ts"), "utf-8"); const resolverSrc = readFileSync( join(__dirname, "..", "worktree-resolver.ts"), "utf-8", @@ -88,7 +88,7 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", ( const incompleteIdx = loopSrc.indexOf("incomplete.length === 0"); assert.ok( incompleteIdx > -1, - "auto-loop.ts should have 'incomplete.length === 0' check", + "auto/phases.ts should have 'incomplete.length === 0' check", ); // The merge call must appear BETWEEN the incomplete check and the stopAuto call. @@ -99,7 +99,7 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", ( assert.ok( blockAfterIncomplete.includes("deps.resolver.mergeAndExit"), - "auto-loop.ts should call resolver.mergeAndExit in the 'all milestones complete' path", + "auto/phases.ts should call resolver.mergeAndExit in the 'all milestones complete' path", ); // The merge should come before stopAuto in this block diff --git a/src/resources/extensions/gsd/tests/atomic-task-closeout.test.ts b/src/resources/extensions/gsd/tests/atomic-task-closeout.test.ts new file mode 100644 index 000000000..fab33427e --- /dev/null +++ b/src/resources/extensions/gsd/tests/atomic-task-closeout.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for atomic task closeout (#1650): + * 1. Doctor unmarks task checkbox when summary is missing (instead of creating stub) + * 2. markTaskUndoneInPlan correctly unchecks a task in the slice plan + */ + +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { runGSDDoctor } from "../doctor.ts"; +import { markTaskUndoneInPlan } from "../roadmap-mutations.ts"; + +function makeTmp(name: string): string { + const dir = join(tmpdir(), `atomic-closeout-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +// ── markTaskUndoneInPlan ───────────────────────────────────────────────────── + +test("markTaskUndoneInPlan unchecks a checked task", () => { + const base = makeTmp("uncheck"); + const planPath = join(base, "PLAN.md"); + writeFileSync(planPath, `# S01: Demo + +## Tasks + +- [x] **T01: First task** \`est:5m\` +- [ ] **T02: Second task** \`est:10m\` +`); + + const changed = markTaskUndoneInPlan(base, planPath, "T01"); + assert.ok(changed, "should return true when plan was modified"); + + const content = readFileSync(planPath, "utf-8"); + assert.ok(content.includes("- [ ] **T01:"), "T01 should be unchecked"); + assert.ok(content.includes("- [ ] **T02:"), "T02 should remain unchecked"); + + rmSync(base, { recursive: true, force: true }); +}); + +test("markTaskUndoneInPlan is idempotent on already-unchecked task", () => { + const base = makeTmp("uncheck-noop"); + const planPath = join(base, "PLAN.md"); + writeFileSync(planPath, `# S01: Demo + +## Tasks + +- [ ] **T01: First task** \`est:5m\` +`); + + const changed = markTaskUndoneInPlan(base, planPath, "T01"); + assert.ok(!changed, "should return false when no change needed"); + + rmSync(base, { recursive: true, force: true }); +}); + +test("markTaskUndoneInPlan handles indented checkboxes", () => { + const base = makeTmp("uncheck-indent"); + const planPath = join(base, "PLAN.md"); + writeFileSync(planPath, `# S01: Demo + +## Tasks + + - [x] **T01: First task** \`est:5m\` +`); + + const changed = markTaskUndoneInPlan(base, planPath, "T01"); + assert.ok(changed, "should handle indented checkboxes"); + + const content = readFileSync(planPath, "utf-8"); + assert.ok(content.includes("[ ] **T01:"), "T01 should be unchecked"); + + rmSync(base, { recursive: true, force: true }); +}); + +// ── Doctor: task_done_missing_summary unchecks instead of stubbing ──────────── + +test("doctor unchecks task when checkbox is marked but summary is missing", async () => { + const base = makeTmp("doctor-uncheck"); + const gsd = join(base, ".gsd"); + const m = join(gsd, "milestones", "M001"); + const s = join(m, "slices", "S01"); + const t = join(s, "tasks"); + mkdirSync(t, { recursive: true }); + + writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Test + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > Demo +`); + + // Task is marked [x] in plan but has no summary file + writeFileSync(join(s, "S01-PLAN.md"), `# S01: Test Slice + +**Goal:** test + +## Tasks + +- [x] **T01: Do stuff** \`est:5m\` +- [ ] **T02: Other stuff** \`est:5m\` +`); + + // T02 has no summary either, but it's unchecked — should be left alone + + // Run doctor in diagnose mode first + const diagnoseReport = await runGSDDoctor(base, { fix: false }); + const issue = diagnoseReport.issues.find(i => i.code === "task_done_missing_summary"); + assert.ok(issue, "should detect task_done_missing_summary"); + assert.equal(issue!.severity, "error"); + + // Run doctor in fix mode + const fixReport = await runGSDDoctor(base, { fix: true }); + const fixApplied = fixReport.fixesApplied.some(f => f.includes("unchecked T01")); + assert.ok(fixApplied, "should have unchecked T01 in the fix log"); + + // Verify the plan now has T01 unchecked + const planContent = readFileSync(join(s, "S01-PLAN.md"), "utf-8"); + assert.ok(planContent.includes("- [ ] **T01:"), "T01 should be unchecked after doctor fix"); + assert.ok(planContent.includes("- [ ] **T02:"), "T02 should remain unchecked"); + + // Verify no stub summary was created + const stubPath = join(t, "T01-SUMMARY.md"); + assert.ok( + !existsSync(stubPath), + "should NOT create a stub summary — task should re-execute instead", + ); + + rmSync(base, { recursive: true, force: true }); +}); + +test("doctor does not touch task with checkbox AND summary both present", async () => { + const base = makeTmp("doctor-ok"); + const gsd = join(base, ".gsd"); + const m = join(gsd, "milestones", "M001"); + const s = join(m, "slices", "S01"); + const t = join(s, "tasks"); + mkdirSync(t, { recursive: true }); + + writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Test + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > Demo +`); + + writeFileSync(join(s, "S01-PLAN.md"), `# S01: Test Slice + +**Goal:** test + +## Tasks + +- [x] **T01: Do stuff** \`est:5m\` +`); + + writeFileSync(join(t, "T01-SUMMARY.md"), `--- +id: T01 +parent: S01 +milestone: M001 +duration: 5m +verification_result: passed +completed_at: 2026-01-01 +--- + +# T01: Do stuff + +Done. +`); + + const report = await runGSDDoctor(base, { fix: true }); + const hasTaskIssue = report.issues.some(i => i.code === "task_done_missing_summary"); + assert.ok(!hasTaskIssue, "should not flag task_done_missing_summary when both exist"); + + // Plan should still have T01 checked + const planContent = readFileSync(join(s, "S01-PLAN.md"), "utf-8"); + assert.ok(planContent.includes("- [x] **T01:"), "T01 should remain checked"); + + rmSync(base, { recursive: true, force: true }); +}); diff --git a/src/resources/extensions/gsd/tests/atomic-write.test.ts b/src/resources/extensions/gsd/tests/atomic-write.test.ts new file mode 100644 index 000000000..3fffc48d3 --- /dev/null +++ b/src/resources/extensions/gsd/tests/atomic-write.test.ts @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + atomicWriteAsyncWithOps, + atomicWriteSyncWithOps, + type AtomicWriteAsyncOps, + type AtomicWriteSyncOps, +} from "../atomic-write.ts"; + +function makeError(code: string, message = code): NodeJS.ErrnoException { + const err = new Error(message) as NodeJS.ErrnoException; + err.code = code; + return err; +} + +function createAsyncHarness(plan: Array) { + const files = new Map(); + const renameCalls: Array<{ from: string; to: string }> = []; + const unlinkCalls: string[] = []; + const sleepCalls: number[] = []; + let tempCounter = 0; + + const ops: AtomicWriteAsyncOps = { + mkdir: async () => {}, + writeFile: async (path, content) => { + files.set(path, String(content)); + }, + rename: async (from, to) => { + renameCalls.push({ from, to }); + const outcome = plan.shift() ?? null; + if (outcome) throw outcome; + const content = files.get(from); + if (content === undefined) throw makeError("ENOENT", "temp missing"); + files.set(to, content); + files.delete(from); + }, + unlink: async (path) => { + unlinkCalls.push(path); + files.delete(path); + }, + sleep: async (ms) => { + sleepCalls.push(ms); + }, + createTempPath: (filePath) => `${filePath}.tmp.test-${++tempCounter}`, + }; + + return { ops, files, renameCalls, unlinkCalls, sleepCalls }; +} + +function createSyncHarness(plan: Array) { + const files = new Map(); + const renameCalls: Array<{ from: string; to: string }> = []; + const unlinkCalls: string[] = []; + const sleepCalls: number[] = []; + let tempCounter = 0; + + const ops: AtomicWriteSyncOps = { + mkdir: () => {}, + writeFile: (path, content) => { + files.set(path, String(content)); + }, + rename: (from, to) => { + renameCalls.push({ from, to }); + const outcome = plan.shift() ?? null; + if (outcome) throw outcome; + const content = files.get(from); + if (content === undefined) throw makeError("ENOENT", "temp missing"); + files.set(to, content); + files.delete(from); + }, + unlink: (path) => { + unlinkCalls.push(path); + files.delete(path); + }, + sleep: (ms) => { + sleepCalls.push(ms); + }, + createTempPath: (filePath) => `${filePath}.tmp.test-${++tempCounter}`, + }; + + return { ops, files, renameCalls, unlinkCalls, sleepCalls }; +} + +test("atomicWriteAsync retries transient rename failures and preserves atomicity", async () => { + const harness = createAsyncHarness([makeError("EBUSY"), makeError("EPERM"), null]); + harness.files.set("C:/tmp/output.txt", "old-content"); + + await atomicWriteAsyncWithOps("C:/tmp/output.txt", "new-content", "utf-8", harness.ops); + + assert.equal(harness.renameCalls.length, 3); + assert.equal(harness.files.get("C:/tmp/output.txt"), "new-content"); + assert.equal(harness.unlinkCalls.length, 0); + assert.equal(harness.sleepCalls.length, 2); +}); + +test("atomicWriteAsync cleans up temp file and reports attempts after repeated transient failures", async () => { + const harness = createAsyncHarness([ + makeError("EACCES"), + makeError("EBUSY"), + makeError("EPERM"), + makeError("EACCES"), + makeError("EBUSY"), + ]); + harness.files.set("C:/tmp/output.txt", "old-content"); + + await assert.rejects( + atomicWriteAsyncWithOps("C:/tmp/output.txt", "new-content", "utf-8", harness.ops), + (error: unknown) => { + assert.match(String(error), /C:\\\/tmp\/output\.txt|C:\/tmp\/output\.txt/); + assert.match(String(error), /attempt/i); + assert.match(String(error), /EBUSY|EPERM|EACCES/); + return true; + }, + ); + + assert.equal(harness.renameCalls.length, 5); + assert.equal(harness.files.get("C:/tmp/output.txt"), "old-content"); + assert.equal(harness.unlinkCalls.length, 1); +}); + +test("atomicWriteAsync does not retry non-transient rename failures", async () => { + const harness = createAsyncHarness([makeError("ENOENT")]); + harness.files.set("C:/tmp/output.txt", "old-content"); + + await assert.rejects(() => atomicWriteAsyncWithOps("C:/tmp/output.txt", "new-content", "utf-8", harness.ops)); + + assert.equal(harness.renameCalls.length, 1); + assert.equal(harness.sleepCalls.length, 0); + assert.equal(harness.unlinkCalls.length, 1); + assert.equal(harness.files.get("C:/tmp/output.txt"), "old-content"); +}); + +test("atomicWriteSync retries transient rename failures and succeeds", () => { + const harness = createSyncHarness([makeError("EACCES"), makeError("EBUSY"), null]); + harness.files.set("C:/tmp/output.txt", "old-content"); + + atomicWriteSyncWithOps("C:/tmp/output.txt", "new-content", "utf-8", harness.ops); + + assert.equal(harness.renameCalls.length, 3); + assert.equal(harness.sleepCalls.length, 2); + assert.equal(harness.unlinkCalls.length, 0); + assert.equal(harness.files.get("C:/tmp/output.txt"), "new-content"); +}); diff --git a/src/resources/extensions/gsd/tests/auto-dashboard.test.ts b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts index 45ca2fb23..4ca0836f9 100644 --- a/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +++ b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts @@ -8,6 +8,7 @@ import { formatAutoElapsed, formatWidgetTokens, estimateTimeRemaining, + extractUatSliceId, } from "../auto-dashboard.ts"; // ─── unitVerb ───────────────────────────────────────────────────────────── @@ -164,3 +165,31 @@ test("estimateTimeRemaining returns null when no ledger data", () => { test("estimateTimeRemaining is exported and callable", () => { assert.equal(typeof estimateTimeRemaining, "function"); }); + +// ─── getAutoDashboardData elapsed guard ────────────────────────────────────── +// These tests verify the elapsed time calculation in getAutoDashboardData() +// doesn't produce absurd values when autoStartTime is 0 (uninitialized). +// The actual function is in auto.ts and tested structurally here by verifying +// that formatAutoElapsed properly handles the zero case. + +test("formatAutoElapsed returns empty string for negative autoStartTime", () => { + // A negative value should be treated as invalid — the guard in + // getAutoDashboardData prevents this, but formatAutoElapsed should also + // handle it gracefully via its falsy check. + assert.equal(formatAutoElapsed(-1), ""); + assert.equal(formatAutoElapsed(NaN), ""); +}); + +// ─── extractUatSliceId ─────────────────────────────────────────────────── + +test("extractUatSliceId extracts slice ID from M001/S01 format", () => { + assert.equal(extractUatSliceId("M001/S01"), "S01"); + assert.equal(extractUatSliceId("M002/S03"), "S03"); + assert.equal(extractUatSliceId("M001/S12"), "S12"); +}); + +test("extractUatSliceId returns null for invalid formats", () => { + assert.equal(extractUatSliceId("M001"), null); + assert.equal(extractUatSliceId(""), null); + assert.equal(extractUatSliceId("M001/T01"), null); +}); diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index d1070021d..9cc2877e5 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; import { resolveAgentEnd, + resolveAgentEndCancelled, runUnit, autoLoop, detectStuck, @@ -247,20 +248,20 @@ test("auto-loop.ts exports autoLoop, runUnit, resolveAgentEnd", async () => { ); }); -test("auto-loop.ts contains a while keyword", () => { +test("auto/loop.ts contains a while keyword", () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "loop.ts"), "utf-8", ); assert.ok( src.includes("while"), - "auto-loop.ts should contain a while keyword (loop or placeholder)", + "auto/loop.ts should contain a while keyword (loop or placeholder)", ); }); -test("auto-loop.ts one-shot pattern: _currentResolve is nulled before calling resolver", () => { +test("auto/resolve.ts one-shot pattern: _currentResolve is nulled before calling resolver", () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "resolve.ts"), "utf-8", ); // The one-shot pattern requires: save ref, null the variable, then call @@ -381,7 +382,7 @@ function makeMockDeps( getDeepDiagnostic: () => null, isDbAvailable: () => false, reorderForCaching: (p: string) => p, - existsSync: () => false, + existsSync: (p: string) => p.endsWith(".git") || p.endsWith("package.json"), readFileSync: () => "", atomicWriteSync: () => {}, GitServiceImpl: class {} as any, @@ -413,6 +414,9 @@ function makeMockDeps( return "continue" as const; }, getSessionFile: () => "/tmp/session.json", + rebuildState: async () => {}, + resolveModelId: (id: string, models: any[]) => models.find((m: any) => m.id === id), + emitJournalEvent: () => {}, }; const merged = { ...baseDeps, ...overrides, callLog }; @@ -664,6 +668,117 @@ test("autoLoop calls deriveState → resolveDispatch → runUnit in sequence", a ); }); +test("crash lock records session file from AFTER newSession, not before (#1710)", async (t) => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + + // Simulate newSession changing the session file path. + // newSession() in runUnit changes the underlying session, so getSessionFile + // returns a different path after newSession completes. + let currentSessionFile = "/tmp/old-session.json"; + ctx.sessionManager = { + getSessionFile: () => currentSessionFile, + }; + const pi = makeMockPi(); + + let loopCount = 0; + const s = makeLoopSession({ + cmdCtx: { + newSession: () => { + // When newSession completes, the session file changes + currentSessionFile = "/tmp/new-session-after-newSession.json"; + return Promise.resolve({ cancelled: false }); + }, + getContextUsage: () => ({ percent: 10, tokens: 1000, limit: 10000 }), + }, + }); + + // Track all writeLock calls with their sessionFile argument + const writeLockCalls: { sessionFile: string | undefined }[] = []; + const updateSessionLockCalls: { sessionFile: string | undefined }[] = []; + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + resolveDispatch: async () => { + deps.callLog.push("resolveDispatch"); + return { + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do the thing", + }; + }, + writeLock: (_base: string, _ut: string, _uid: string, _count: number, sessionFile?: string) => { + writeLockCalls.push({ sessionFile }); + }, + updateSessionLock: (_base: string, _ut: string, _uid: string, _count: number, sessionFile?: string) => { + updateSessionLockCalls.push({ sessionFile }); + }, + getSessionFile: (ctxArg: any) => { + return ctxArg.sessionManager?.getSessionFile() ?? ""; + }, + postUnitPostVerification: async () => { + deps.callLog.push("postUnitPostVerification"); + loopCount++; + if (loopCount >= 1) { + s.active = false; + } + return "continue" as const; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + // Give the loop time to reach runUnit's await + await new Promise((r) => setTimeout(r, 50)); + + // Resolve the unit's agent_end + resolveAgentEnd(makeEvent()); + + await loopPromise; + + // The preliminary lock (before runUnit) should have NO session file + assert.ok( + writeLockCalls.length >= 2, + `expected at least 2 writeLock calls, got ${writeLockCalls.length}`, + ); + assert.strictEqual( + writeLockCalls[0].sessionFile, + undefined, + "preliminary lock before runUnit should have no session file", + ); + + // The post-runUnit lock should have the NEW session file path + assert.strictEqual( + writeLockCalls[1].sessionFile, + "/tmp/new-session-after-newSession.json", + "post-runUnit lock should record the session file created by newSession", + ); + + // updateSessionLock should also have the new session file + assert.ok( + updateSessionLockCalls.length >= 1, + "updateSessionLock should have been called at least once", + ); + assert.strictEqual( + updateSessionLockCalls[0].sessionFile, + "/tmp/new-session-after-newSession.json", + "updateSessionLock should record the session file created by newSession", + ); +}); + test("autoLoop handles verification retry by continuing loop", async (t) => { _resetPendingResolve(); @@ -893,18 +1008,18 @@ test("autoLoop exits when no active milestone found", async (t) => { test("autoLoop exports LoopDeps type", async () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "loop-deps.ts"), "utf-8", ); assert.ok( src.includes("export interface LoopDeps"), - "auto-loop.ts should export LoopDeps interface", + "auto/loop-deps.ts should export LoopDeps interface", ); }); test("autoLoop signature accepts deps parameter", async () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "loop.ts"), "utf-8", ); assert.ok( @@ -915,7 +1030,7 @@ test("autoLoop signature accepts deps parameter", async () => { test("autoLoop contains while (s.active) loop", () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "loop.ts"), "utf-8", ); assert.ok( @@ -926,22 +1041,47 @@ test("autoLoop contains while (s.active) loop", () => { // ── T03: End-to-end wiring structural assertions ───────────────────────────── -test("auto-loop.ts exports autoLoop, runUnit, and resolveAgentEnd", () => { - const src = readFileSync( +test("auto-loop.ts barrel re-exports autoLoop, runUnit, and resolveAgentEnd", () => { + const barrel = readFileSync( resolve(import.meta.dirname, "..", "auto-loop.ts"), "utf-8", ); assert.ok( - src.includes("export async function autoLoop"), - "must export autoLoop", + barrel.includes("autoLoop"), + "barrel must re-export autoLoop", ); assert.ok( - src.includes("export async function runUnit"), - "must export runUnit", + barrel.includes("runUnit"), + "barrel must re-export runUnit", ); assert.ok( - src.includes("export function resolveAgentEnd"), - "must export resolveAgentEnd", + barrel.includes("resolveAgentEnd"), + "barrel must re-export resolveAgentEnd", + ); + // Verify the actual function declarations exist in the submodules + const loopSrc = readFileSync( + resolve(import.meta.dirname, "..", "auto", "loop.ts"), + "utf-8", + ); + assert.ok( + loopSrc.includes("export async function autoLoop"), + "auto/loop.ts must define autoLoop", + ); + const runUnitSrc = readFileSync( + resolve(import.meta.dirname, "..", "auto", "run-unit.ts"), + "utf-8", + ); + assert.ok( + runUnitSrc.includes("export async function runUnit"), + "auto/run-unit.ts must define runUnit", + ); + const resolveSrc = readFileSync( + resolve(import.meta.dirname, "..", "auto", "resolve.ts"), + "utf-8", + ); + assert.ok( + resolveSrc.includes("export function resolveAgentEnd"), + "auto/resolve.ts must define resolveAgentEnd", ); }); @@ -962,6 +1102,32 @@ test("auto.ts startAuto calls autoLoop (not dispatchNextUnit as first dispatch)" ); }); +test("startAuto calls selfHealRuntimeRecords before autoLoop (#1727)", () => { + const src = readFileSync( + resolve(import.meta.dirname, "..", "auto.ts"), + "utf-8", + ); + const fnIdx = src.indexOf("export async function startAuto"); + assert.ok(fnIdx > -1, "startAuto must exist in auto.ts"); + const fnEnd = src.indexOf("\n// ─── ", fnIdx + 100); + const fnBlock = + fnEnd > -1 ? src.slice(fnIdx, fnEnd) : src.slice(fnIdx, fnIdx + 5000); + + // Both autoLoop call sites must be preceded by selfHealRuntimeRecords + const healIdx = fnBlock.indexOf("selfHealRuntimeRecords"); + const loopIdx = fnBlock.indexOf("autoLoop("); + assert.ok(healIdx > -1, "startAuto must call selfHealRuntimeRecords"); + assert.ok(healIdx < loopIdx, "selfHealRuntimeRecords must be called before autoLoop"); + + // Verify the second autoLoop call site also has selfHeal before it + const secondLoopIdx = fnBlock.indexOf("autoLoop(", loopIdx + 1); + if (secondLoopIdx > -1) { + const secondHealIdx = fnBlock.indexOf("selfHealRuntimeRecords", healIdx + 1); + assert.ok(secondHealIdx > -1, "second autoLoop call must also have selfHealRuntimeRecords"); + assert.ok(secondHealIdx < secondLoopIdx, "second selfHealRuntimeRecords must precede second autoLoop"); + } +}); + test("agent_end handler calls resolveAgentEnd (not handleAgentEnd)", () => { const hooksSrc = readFileSync( resolve(import.meta.dirname, "..", "bootstrap", "register-hooks.ts"), @@ -1341,23 +1507,23 @@ test("detectStuck: truncates long error strings", () => { }); test("stuck detection: logs debug output with stuck-detected phase", () => { - // Structural test: verify the auto-loop.ts source contains + // Structural test: verify auto/phases.ts contains // stuck-detected and stuck-counter-reset debug log phases, plus detectStuck const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "phases.ts"), "utf-8", ); assert.ok( src.includes('"stuck-detected"'), - "auto-loop.ts must log phase: 'stuck-detected' when stuck detection fires", + "auto/phases.ts must log phase: 'stuck-detected' when stuck detection fires", ); assert.ok( src.includes('"stuck-counter-reset"'), - "auto-loop.ts must log phase: 'stuck-counter-reset' when recovery resets on new unit", + "auto/phases.ts must log phase: 'stuck-counter-reset' when recovery resets on new unit", ); assert.ok( src.includes("detectStuck"), - "auto-loop.ts must use detectStuck for sliding window analysis", + "auto/phases.ts must use detectStuck for sliding window analysis", ); }); @@ -1552,3 +1718,415 @@ test("autoLoop lifecycle: advances through research → plan → execute → ver "dispatched unit types should follow the full lifecycle sequence", ); }); + +// ─── resolveAgentEndCancelled tests ────────────────────────────────────────── + +test("resolveAgentEndCancelled resolves a pending promise with cancelled status", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession(); + + const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt"); + + await new Promise((r) => setTimeout(r, 10)); + + resolveAgentEndCancelled(); + + const result = await resultPromise; + assert.equal(result.status, "cancelled"); + assert.equal(result.event, undefined); +}); + +test("resolveAgentEndCancelled is a no-op when no promise is pending", () => { + _resetPendingResolve(); + + assert.doesNotThrow(() => { + resolveAgentEndCancelled(); + }); +}); + +test("resolveAgentEndCancelled prevents orphaned promise after abort path", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession(); + + const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt"); + + await new Promise((r) => setTimeout(r, 10)); + + s.active = false; + resolveAgentEndCancelled(); + + const result = await resultPromise; + assert.equal(result.status, "cancelled"); +}); + +// ─── #1571: artifact verification retry ────────────────────────────────────── + +test("autoLoop re-iterates when postUnitPreVerification returns retry (#1571)", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + const pi = makeMockPi(); + const s = makeLoopSession(); + + let preVerifyCallCount = 0; + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + postUnitPreVerification: async () => { + deps.callLog.push("postUnitPreVerification"); + preVerifyCallCount++; + if (preVerifyCallCount === 1) { + return "retry" as const; + } + return "continue" as const; + }, + postUnitPostVerification: async () => { + deps.callLog.push("postUnitPostVerification"); + s.active = false; + return "continue" as const; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + await new Promise((r) => setTimeout(r, 50)); + resolveAgentEnd(makeEvent()); + + await new Promise((r) => setTimeout(r, 50)); + resolveAgentEnd(makeEvent()); + + await loopPromise; + + assert.equal(preVerifyCallCount, 2, "preVerification should be called twice"); + + const postVerifyCalls = deps.callLog.filter( + (c: string) => c === "runPostUnitVerification", + ); + const postPostVerifyCalls = deps.callLog.filter( + (c: string) => c === "postUnitPostVerification", + ); + + assert.equal(postVerifyCalls.length, 1, "runPostUnitVerification should only be called once"); + assert.equal(postPostVerifyCalls.length, 1, "postUnitPostVerification should only be called once"); +}); + +// ─── stopAuto unitPromise leak regression (#1799) ──────────────────────────── + +test("resolveAgentEnd unblocks pending runUnit when called before session reset (#1799)", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const s = makeMockSession(); + + const resultPromise = runUnit(ctx, pi, s, "task", "T01", "do work"); + + await new Promise((r) => setTimeout(r, 10)); + + resolveAgentEnd({ messages: [] }); + _resetPendingResolve(); + s.active = false; + + const result = await resultPromise; + assert.equal(result.status, "completed", "runUnit should resolve, not hang"); +}); + +// ─── Zero tool-call hallucination guard (#1833) ─────────────────────────── + +test("autoLoop rejects execute-task with 0 tool calls as hallucinated (#1833)", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; + const pi = makeMockPi(); + + let iterationCount = 0; + const notifications: string[] = []; + ctx.ui.notify = (msg: string) => { notifications.push(msg); }; + + const s = makeLoopSession(); + + // Mock ledger: execute-task completed with 0 tool calls + const mockLedger = { + version: 1, + projectStartedAt: Date.now(), + units: [] as any[], + }; + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + resolveDispatch: async () => { + deps.callLog.push("resolveDispatch"); + return { + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "implement the feature", + }; + }, + closeoutUnit: async () => { + // Simulate snapshotUnitMetrics adding a 0-toolCalls entry to ledger + mockLedger.units.push({ + type: "execute-task", + id: "M001/S01/T01", + startedAt: s.currentUnit?.startedAt ?? Date.now(), + toolCalls: 0, + assistantMessages: 1, + tokens: { input: 100, output: 200, total: 300, cacheRead: 0, cacheWrite: 0 }, + cost: 0.50, + }); + }, + getLedger: () => mockLedger, + postUnitPostVerification: async () => { + deps.callLog.push("postUnitPostVerification"); + iterationCount++; + if (iterationCount >= 2) { + s.active = false; + } + return "continue" as const; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + // First iteration: execute-task with 0 tool calls → rejected + await new Promise((r) => setTimeout(r, 50)); + resolveAgentEnd(makeEvent()); + + // Second iteration: same task re-dispatched, this time with tool calls + await new Promise((r) => setTimeout(r, 50)); + mockLedger.units.length = 0; // clear previous entry + (deps as any).closeoutUnit = async () => { + mockLedger.units.push({ + type: "execute-task", + id: "M001/S01/T01", + startedAt: s.currentUnit?.startedAt ?? Date.now(), + toolCalls: 5, + assistantMessages: 3, + tokens: { input: 500, output: 800, total: 1300, cacheRead: 0, cacheWrite: 0 }, + cost: 1.00, + }); + }; + resolveAgentEnd(makeEvent()); + + await loopPromise; + + // The task should NOT have been added to completedUnits on the first iteration + // (0 tool calls), but SHOULD be added on the second iteration (5 tool calls) + const warningNotification = notifications.find( + (n) => n.includes("0 tool calls") && n.includes("hallucinated"), + ); + assert.ok( + warningNotification, + "should notify about 0 tool calls hallucination", + ); + + // Verify deriveState was called at least twice (two iterations) + const deriveCount = deps.callLog.filter((c) => c === "deriveState").length; + assert.ok( + deriveCount >= 2, + `deriveState should be called at least 2 times for retry (got ${deriveCount})`, + ); +}); + +test("autoLoop does NOT reject non-execute-task units with 0 tool calls (#1833)", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; + const pi = makeMockPi(); + + const notifications: string[] = []; + ctx.ui.notify = (msg: string) => { notifications.push(msg); }; + + const s = makeLoopSession(); + + const mockLedger = { + version: 1, + projectStartedAt: Date.now(), + units: [] as any[], + }; + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + resolveDispatch: async () => { + deps.callLog.push("resolveDispatch"); + return { + action: "dispatch" as const, + unitType: "complete-slice", + unitId: "M001/S01", + prompt: "complete the slice", + }; + }, + closeoutUnit: async () => { + // complete-slice with 0 tool calls is fine (e.g. it may just update status) + mockLedger.units.push({ + type: "complete-slice", + id: "M001/S01", + startedAt: s.currentUnit?.startedAt ?? Date.now(), + toolCalls: 0, + assistantMessages: 1, + tokens: { input: 50, output: 100, total: 150, cacheRead: 0, cacheWrite: 0 }, + cost: 0.10, + }); + }, + getLedger: () => mockLedger, + verifyExpectedArtifact: () => true, + postUnitPostVerification: async () => { + deps.callLog.push("postUnitPostVerification"); + s.active = false; + return "continue" as const; + }, + }); + + const loopPromise = autoLoop(ctx, pi, s, deps); + + await new Promise((r) => setTimeout(r, 50)); + resolveAgentEnd(makeEvent()); + + await loopPromise; + + // Should NOT have a hallucination warning for non-execute-task units + const warningNotification = notifications.find( + (n) => n.includes("0 tool calls") && n.includes("hallucinated"), + ); + assert.ok( + !warningNotification, + "should NOT flag non-execute-task units with 0 tool calls", + ); + + // The unit should have been added to completedUnits normally + assert.ok( + s.completedUnits.length >= 1, + "complete-slice with 0 tool calls should still be marked as completed", + ); +}); + +// ─── Worktree health check (#1833) ──────────────────────────────────────── + +test("autoLoop stops when worktree has no .git for execute-task (#1833)", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; + const pi = makeMockPi(); + + const notifications: string[] = []; + ctx.ui.notify = (msg: string) => { notifications.push(msg); }; + + const s = makeLoopSession({ basePath: "/tmp/broken-worktree" }); + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + // .git does not exist in the broken worktree + existsSync: (p: string) => !p.endsWith(".git"), + }); + + await autoLoop(ctx, pi, s, deps); + + assert.ok( + deps.callLog.includes("stopAuto"), + "should stop auto-mode when worktree is invalid", + ); + const healthNotification = notifications.find( + (n) => n.includes("Worktree health check failed") && n.includes("no .git"), + ); + assert.ok( + healthNotification, + "should notify about missing .git in worktree", + ); +}); + +test("autoLoop stops when worktree has no project files for execute-task (#1833)", async () => { + _resetPendingResolve(); + + const ctx = makeMockCtx(); + ctx.ui.setStatus = () => {}; + ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; + const pi = makeMockPi(); + + const notifications: string[] = []; + ctx.ui.notify = (msg: string) => { notifications.push(msg); }; + + const s = makeLoopSession({ basePath: "/tmp/empty-worktree" }); + + const deps = makeMockDeps({ + deriveState: async () => { + deps.callLog.push("deriveState"); + return { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any; + }, + // Has .git but no package.json or src/ + existsSync: (p: string) => p.endsWith(".git"), + }); + + await autoLoop(ctx, pi, s, deps); + + assert.ok( + deps.callLog.includes("stopAuto"), + "should stop auto-mode when worktree has no project files", + ); + const healthNotification = notifications.find( + (n) => n.includes("Worktree health check failed") && n.includes("no recognized project files"), + ); + assert.ok( + healthNotification, + "should notify about missing project files in worktree", + ); +}); diff --git a/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts b/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts new file mode 100644 index 000000000..addbefa22 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts @@ -0,0 +1,143 @@ +/** + * auto-paused-session-validation.test.ts — Validates milestone existence + * before restoring from paused-session.json (#1664). + * + * Two layers: + * 1. Source-code regression: ensures auto.ts validates the milestone before + * trusting paused-session.json (guards against accidental removal). + * 2. Filesystem unit: confirms resolveMilestonePath / resolveMilestoneFile + * correctly detect missing and completed milestones. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +import { resolveMilestonePath, resolveMilestoneFile } from "../paths.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const AUTO_TS_PATH = join(__dirname, "..", "auto.ts"); + +// ─── Source-code regression guard ─────────────────────────────────────────── + +test("auto.ts validates milestone before restoring paused session (#1664)", () => { + const source = readFileSync(AUTO_TS_PATH, "utf-8"); + + // The resume block must call resolveMilestonePath to verify the milestone dir exists + assert.ok( + source.includes('resolveMilestonePath(base, meta.milestoneId)'), + "auto.ts must call resolveMilestonePath to verify paused milestone exists", + ); + + // The resume block must check for a SUMMARY file to detect completed milestones + assert.ok( + source.includes('resolveMilestoneFile(base, meta.milestoneId, "SUMMARY")'), + "auto.ts must check for SUMMARY file to detect completed milestones", + ); +}); + +// ─── Filesystem validation unit tests ─────────────────────────────────────── + +function makeTmpBase(): string { + return join(tmpdir(), `gsd-paused-test-${randomUUID()}`); +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +test("resolveMilestonePath returns null for missing milestone", () => { + const base = makeTmpBase(); + mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); + try { + const result = resolveMilestonePath(base, "M999"); + assert.equal(result, null, "should return null for non-existent milestone"); + } finally { + cleanup(base); + } +}); + +test("resolveMilestonePath returns path for existing milestone", () => { + const base = makeTmpBase(); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + try { + const result = resolveMilestonePath(base, "M001"); + assert.ok(result, "should return a path for existing milestone"); + assert.ok(result.includes("M001"), "path should contain the milestone ID"); + } finally { + cleanup(base); + } +}); + +test("resolveMilestoneFile returns null when no SUMMARY exists", () => { + const base = makeTmpBase(); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + try { + const result = resolveMilestoneFile(base, "M001", "SUMMARY"); + assert.equal(result, null, "should return null when no SUMMARY file"); + } finally { + cleanup(base); + } +}); + +test("resolveMilestoneFile returns path when SUMMARY exists (completed)", () => { + const base = makeTmpBase(); + const mDir = join(base, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-SUMMARY.md"), "# Summary\nDone."); + try { + const result = resolveMilestoneFile(base, "M001", "SUMMARY"); + assert.ok(result, "should return a path when SUMMARY exists"); + assert.ok(result.includes("SUMMARY"), "path should reference SUMMARY"); + } finally { + cleanup(base); + } +}); + +// ─── Combined validation logic (mirrors auto.ts resume guard) ─────────────── + +test("stale milestone: missing dir means paused session should be discarded", () => { + const base = makeTmpBase(); + mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); + try { + const mDir = resolveMilestonePath(base, "M999"); + const summaryFile = resolveMilestoneFile(base, "M999", "SUMMARY"); + const isStale = !mDir || !!summaryFile; + assert.ok(isStale, "milestone that doesn't exist should be detected as stale"); + } finally { + cleanup(base); + } +}); + +test("stale milestone: completed (has SUMMARY) means paused session should be discarded", () => { + const base = makeTmpBase(); + const mDir = join(base, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-SUMMARY.md"), "# Summary\nDone."); + try { + const dir = resolveMilestonePath(base, "M001"); + const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY"); + const isStale = !dir || !!summaryFile; + assert.ok(isStale, "milestone with SUMMARY should be detected as stale"); + } finally { + cleanup(base); + } +}); + +test("valid milestone: exists and has no SUMMARY means paused session is valid", () => { + const base = makeTmpBase(); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + try { + const dir = resolveMilestonePath(base, "M001"); + const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY"); + const isStale = !dir || !!summaryFile; + assert.ok(!isStale, "active milestone should not be detected as stale"); + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index 19993441c..c1a3d0faf 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -11,6 +11,7 @@ import { diagnoseExpectedArtifact, buildLoopRemediationSteps, selfHealRuntimeRecords, + hasImplementationArtifacts, } from "../auto-recovery.ts"; import { parseRoadmap, clearParseCache } from "../files.ts"; import { invalidateAllCaches } from "../cache.ts"; @@ -450,6 +451,91 @@ test("verifyExpectedArtifact plan-slice fails for plan with no tasks (#699)", () } }); +// ─── verifyExpectedArtifact: heading-style plan tasks (#1691) ───────────── + +test("verifyExpectedArtifact accepts plan-slice with heading-style tasks (### T01 --)", () => { + const base = makeTmpBase(); + try { + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "### T01 -- Implement feature", + "", + "Feature description.", + "", + "### T02 -- Write tests", + "", + "Test description.", + ].join("\n")); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); + writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan"); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + true, + "Heading-style plan with task entries should be treated as completed artifact", + ); + } finally { + cleanup(base); + } +}); + +test("verifyExpectedArtifact accepts plan-slice with colon-style heading tasks (### T01:)", () => { + const base = makeTmpBase(); + try { + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "### T01: Implement feature", + "", + "Feature description.", + ].join("\n")); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + true, + "Colon heading-style plan should be treated as completed artifact", + ); + } finally { + cleanup(base); + } +}); + +test("verifyExpectedArtifact execute-task passes for heading-style plan entry (#1691)", () => { + const base = makeTmpBase(); + try { + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "### T01 -- Implement feature", + "", + "Feature description.", + ].join("\n")); + writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone."); + assert.strictEqual( + verifyExpectedArtifact("execute-task", "M001/S01/T01", base), + true, + "execute-task should pass for heading-style plan entry when summary exists", + ); + } finally { + cleanup(base); + } +}); + // ─── selfHealRuntimeRecords — worktree base path (#769) ────────────────── test("selfHealRuntimeRecords clears stale dispatched records (#769)", async () => { @@ -498,6 +584,39 @@ test("selfHealRuntimeRecords clears stale dispatched records (#769)", async () = } }); +// ─── #1625: selfHealRuntimeRecords on resume clears paused-session leftovers ── + +test("selfHealRuntimeRecords clears recently-paused dispatched records on resume (#1625)", async () => { + // When pauseAuto closes out a unit but clearUnitRuntimeRecord silently fails + // (e.g. permission error), selfHealRuntimeRecords on resume should still + // clean up stale dispatched records that are >1h old. + const base = makeTmpBase(); + try { + const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts"); + + // Simulate a record left behind after a pause — aged >1h to be considered stale + writeUnitRuntimeRecord(base, "execute-task", "M001/S01/T01", Date.now() - 3700_000, { + phase: "dispatched", + }); + + const before = readUnitRuntimeRecord(base, "execute-task", "M001/S01/T01"); + assert.ok(before, "dispatched record should exist before resume heal"); + assert.equal(before!.phase, "dispatched"); + + const notifications: string[] = []; + const mockCtx = { + ui: { notify: (msg: string) => { notifications.push(msg); } }, + } as any; + + await selfHealRuntimeRecords(base, mockCtx); + + const after = readUnitRuntimeRecord(base, "execute-task", "M001/S01/T01"); + assert.equal(after, null, "stale dispatched record should be cleared on resume (#1625)"); + } finally { + cleanup(base); + } +}); + // ─── #793: invalidateAllCaches unblocks skip-loop ───────────────────────── // When the skip-loop breaker fires, it must call invalidateAllCaches() (not // just invalidateStateCache()) to clear path/parse caches that deriveState @@ -549,3 +668,106 @@ test("#793: invalidateAllCaches clears all caches so deriveState sees fresh disk cleanup(base); } }); + +// ─── hasImplementationArtifacts (#1703) ─────────────────────────────────── + +import { execFileSync } from "node:child_process"; + +function makeGitBase(): string { + const base = join(tmpdir(), `gsd-test-git-${randomUUID()}`); + mkdirSync(base, { recursive: true }); + execFileSync("git", ["init", "--initial-branch=main"], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: base, stdio: "ignore" }); + // Create initial commit so HEAD exists + writeFileSync(join(base, ".gitkeep"), ""); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: base, stdio: "ignore" }); + return base; +} + +test("hasImplementationArtifacts returns false when only .gsd/ files committed (#1703)", () => { + const base = makeGitBase(); + try { + // Create a feature branch and commit only .gsd/ files + execFileSync("git", ["checkout", "-b", "feat/test-milestone"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap"); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Summary"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "chore: add plan files"], { cwd: base, stdio: "ignore" }); + + const result = hasImplementationArtifacts(base); + assert.equal(result, false, "should return false when only .gsd/ files were committed"); + } finally { + cleanup(base); + } +}); + +test("hasImplementationArtifacts returns true when implementation files committed (#1703)", () => { + const base = makeGitBase(); + try { + // Create a feature branch with both .gsd/ and implementation files + execFileSync("git", ["checkout", "-b", "feat/test-impl"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap"); + mkdirSync(join(base, "src"), { recursive: true }); + writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "feat: add feature"], { cwd: base, stdio: "ignore" }); + + const result = hasImplementationArtifacts(base); + assert.equal(result, true, "should return true when implementation files are present"); + } finally { + cleanup(base); + } +}); + +test("hasImplementationArtifacts returns true on non-git directory (fail-open)", () => { + const base = join(tmpdir(), `gsd-test-nogit-${randomUUID()}`); + mkdirSync(base, { recursive: true }); + try { + const result = hasImplementationArtifacts(base); + assert.equal(result, true, "should return true (fail-open) in non-git directory"); + } finally { + cleanup(base); + } +}); + +// ─── verifyExpectedArtifact: complete-milestone requires impl artifacts (#1703) ── + +test("verifyExpectedArtifact complete-milestone fails with only .gsd/ files (#1703)", () => { + const base = makeGitBase(); + try { + // Create feature branch with only .gsd/ files + execFileSync("git", ["checkout", "-b", "feat/ms-only-gsd"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone."); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "chore: milestone plan files"], { cwd: base, stdio: "ignore" }); + + const result = verifyExpectedArtifact("complete-milestone", "M001", base); + assert.equal(result, false, "complete-milestone should fail verification when only .gsd/ files present"); + } finally { + cleanup(base); + } +}); + +test("verifyExpectedArtifact complete-milestone passes with impl files (#1703)", () => { + const base = makeGitBase(); + try { + // Create feature branch with implementation files AND milestone summary + execFileSync("git", ["checkout", "-b", "feat/ms-with-impl"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone."); + mkdirSync(join(base, "src"), { recursive: true }); + writeFileSync(join(base, "src", "app.ts"), "console.log('hello');"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "feat: implementation"], { cwd: base, stdio: "ignore" }); + + const result = verifyExpectedArtifact("complete-milestone", "M001", base); + assert.equal(result, true, "complete-milestone should pass verification with implementation files"); + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/auto-start-needs-discussion.test.ts b/src/resources/extensions/gsd/tests/auto-start-needs-discussion.test.ts new file mode 100644 index 000000000..7f5bc2a59 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-start-needs-discussion.test.ts @@ -0,0 +1,240 @@ +/** + * auto-start-needs-discussion.test.ts — Regression tests for #1726. + * + * When a milestone has only CONTEXT-DRAFT.md (phase: needs-discussion), + * bootstrapAutoSession had two bugs: + * + * 1. The survivor branch check included needs-discussion, so a branch + * created by a prior failed bootstrap caused hasSurvivorBranch = true, + * skipping all showSmartEntry calls. + * + * 2. No needs-discussion handler existed in the !hasSurvivorBranch block, + * so the phase fell through to auto-mode which immediately stopped + * with "needs its own discussion before planning." + * + * Together these created an infinite loop: /gsd creates worktree + branch, + * stops immediately, next run detects the branch and skips entry, auto-mode + * dispatches needs-discussion → stop, repeat. + * + * These tests verify: + * - deriveState correctly identifies needs-discussion phase + * - The survivor branch filter in auto-start.ts excludes needs-discussion + * - The !hasSurvivorBranch block has a needs-discussion handler + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +import { deriveState } from "../state.ts"; +import { invalidateAllCaches } from "../cache.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── Fixture Helpers ───────────────────────────────────────────────────────── + +function createBase(): string { + const base = mkdtempSync(join(tmpdir(), "gsd-needs-discussion-")); + mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +function writeContextDraft(base: string, mid: string, content: string): void { + const dir = join(base, ".gsd", "milestones", mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-CONTEXT-DRAFT.md`), content); +} + +function writeContext(base: string, mid: string, content: string): void { + const dir = join(base, ".gsd", "milestones", mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-CONTEXT.md`), content); +} + +function writeRoadmap(base: string, mid: string, content: string): void { + const dir = join(base, ".gsd", "milestones", mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-ROADMAP.md`), content); +} + +// ─── Source code analysis helper ───────────────────────────────────────────── + +function readAutoStartSource(): string { + const thisFile = fileURLToPath(import.meta.url); + const thisDir = dirname(thisFile); + return readFileSync(join(thisDir, "..", "auto-start.ts"), "utf-8"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── 1. deriveState returns needs-discussion for CONTEXT-DRAFT only ──────── + console.log("\n=== 1. CONTEXT-DRAFT.md only → needs-discussion phase ==="); + { + const base = createBase(); + try { + writeContextDraft(base, "M001", "# Draft\nSeed discussion."); + invalidateAllCaches(); + const state = await deriveState(base); + assertEq(state.phase, "needs-discussion", + "milestone with only CONTEXT-DRAFT should be needs-discussion"); + assertTrue(!!state.activeMilestone, + "activeMilestone should be set for needs-discussion"); + assertEq(state.activeMilestone?.id, "M001", + "activeMilestone.id should be M001"); + } finally { + cleanup(base); + } + } + + // ─── 2. Survivor branch filter excludes needs-discussion (#1726 bug 1) ──── + console.log("\n=== 2. Survivor branch check excludes needs-discussion ==="); + { + const source = readAutoStartSource(); + + // Find the survivor branch check block (Milestone branch recovery comment) + const survivorBlock = source.match( + /\/\/ Milestone branch recovery.*?hasSurvivorBranch = nativeBranchExists/s, + ); + assertTrue(!!survivorBlock, + "found survivor branch check block in auto-start.ts"); + + if (survivorBlock) { + const block = survivorBlock[0]; + // The condition should only check pre-planning, NOT needs-discussion + assertTrue(!block.includes("needs-discussion"), + "survivor branch filter must NOT include needs-discussion phase"); + assertTrue(block.includes("pre-planning"), + "survivor branch filter should include pre-planning phase"); + } + } + + // ─── 3. needs-discussion handler exists in !hasSurvivorBranch block (#1726 bug 2) + console.log("\n=== 3. needs-discussion handler exists in bootstrap ==="); + { + const source = readAutoStartSource(); + + // After the pre-planning handler, there should be a needs-discussion handler + // that calls showSmartEntry + const needsDiscussionHandler = source.match( + /if\s*\(state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showSmartEntry/s, + ); + assertTrue(!!needsDiscussionHandler, + "needs-discussion handler calling showSmartEntry must exist in !hasSurvivorBranch block"); + } + + // ─── 4. needs-discussion handler aborts if discussion doesn't promote draft + console.log("\n=== 4. needs-discussion handler has abort path ==="); + { + const source = readAutoStartSource(); + + // The handler should check postState.phase !== "needs-discussion" and abort + // if discussion didn't promote the draft + assertTrue( + source.includes('postState.phase !== "needs-discussion"'), + "needs-discussion handler must check if phase advanced after showSmartEntry", + ); + assertTrue( + source.includes("milestone draft was not promoted"), + "needs-discussion handler must have abort message when draft not promoted", + ); + } + + // ─── 5. CONTEXT-DRAFT + CONTEXT + ROADMAP → not needs-discussion ────────── + console.log("\n=== 5. Full context + roadmap → not needs-discussion ==="); + { + const base = createBase(); + try { + writeContextDraft(base, "M001", "# Draft\nSeed discussion."); + writeContext(base, "M001", "# Context\nFull context."); + writeRoadmap(base, "M001", + "# M001: Test\n\n## Slices\n- [ ] **S01: Test Slice** `risk:low` `depends:[]`\n > After this: works\n"); + invalidateAllCaches(); + const state = await deriveState(base); + assertTrue(state.phase !== "needs-discussion", + "milestone with full context + roadmap should NOT be needs-discussion"); + } finally { + cleanup(base); + } + } + + // ─── 6. Verify the two bug conditions cannot produce infinite loop ──────── + console.log("\n=== 6. No infinite loop: needs-discussion always routes to showSmartEntry ==="); + { + const source = readAutoStartSource(); + + // Verify needs-discussion does NOT appear in auto-dispatch trigger conditions + // within auto-start.ts. The only place needs-discussion should appear is in + // the showSmartEntry routing block. + const survivorSection = source.match( + /\/\/ Milestone branch recovery.*?let hasSurvivorBranch = false;[\s\S]*?if\s*\([^)]*state\.phase[^)]*\)\s*\{/, + ); + if (survivorSection) { + assertTrue( + !survivorSection[0].includes("needs-discussion"), + "survivor branch phase condition must not mention needs-discussion", + ); + } + + // Verify needs-discussion IS handled inside the !hasSurvivorBranch block + const notSurvivorBlock = source.match( + /if\s*\(!hasSurvivorBranch\)\s*\{([\s\S]*?)\/\/ Unreachable safety check/, + ); + assertTrue(!!notSurvivorBlock, + "found !hasSurvivorBranch block in auto-start.ts"); + if (notSurvivorBlock) { + assertTrue( + notSurvivorBlock[1].includes('"needs-discussion"'), + "!hasSurvivorBranch block must handle needs-discussion phase", + ); + } + } + + // ─── 7. Survivor branch + needs-discussion routes to showSmartEntry (#1726) ─ + console.log("\n=== 7. Survivor branch + needs-discussion routes to showSmartEntry ==="); + { + const source = readAutoStartSource(); + + // When hasSurvivorBranch is true AND phase is needs-discussion, the code + // must route to showSmartEntry instead of falling through to auto-mode. + const survivorNeedsDiscussion = source.match( + /if\s*\(hasSurvivorBranch\s*&&\s*state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showSmartEntry/s, + ); + assertTrue(!!survivorNeedsDiscussion, + "hasSurvivorBranch && needs-discussion must route to showSmartEntry"); + + // Verify the handler checks if the discussion succeeded + const handlerBlock = source.match( + /if\s*\(hasSurvivorBranch\s*&&\s*state\.phase\s*===\s*"needs-discussion"\)\s*\{([\s\S]*?)\n \}/, + ); + assertTrue(!!handlerBlock, + "found survivor + needs-discussion handler block"); + if (handlerBlock) { + assertTrue( + handlerBlock[1].includes('postState.phase !== "needs-discussion"'), + "handler must check if phase advanced after discussion", + ); + assertTrue( + handlerBlock[1].includes("releaseLockAndReturn"), + "handler must abort if discussion didn't promote draft", + ); + } + } + + report(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index 0ea4d05ff..0bbf0c39d 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -17,6 +17,7 @@ import { getAutoWorktreeOriginalBase, } from "../auto-worktree.ts"; import { getSliceBranchName } from "../worktree.ts"; +import { nativeMergeSquash } from "../native-git-bridge.ts"; import { createTestContext } from "./test-helpers.ts"; @@ -180,8 +181,8 @@ async function main(): Promise { assertTrue(gitMsg.includes("- S01: Core API"), "git commit body has S01"); } - // ─── Test 3: Nothing to commit — no changes ──────────────────────── - console.log("\n=== nothing to commit — no changes ==="); + // ─── Test 3: Nothing to commit — preserves branch (#1738) ────────── + console.log("\n=== nothing to commit — safe when no code changes (#1738, #1792) ==="); { const repo = freshRepo(); const wtPath = createAutoWorktree(repo, "M030"); @@ -189,15 +190,17 @@ async function main(): Promise { // Don't add any slices/changes — milestone branch is identical to main const roadmap = makeRoadmap("M030", "Empty milestone", []); - // Should complete without throwing + // Should NOT throw — milestone branch is identical to main, nothing to lose. + // The anchor check (#1792) verifies no code files differ and passes through. let threw = false; + let errorMsg = ""; try { - const result = mergeMilestoneToMain(repo, "M030", roadmap); - assertTrue(typeof result.pushed === "boolean", "returns result even with nothing to commit"); - } catch { + mergeMilestoneToMain(repo, "M030", roadmap); + } catch (err: unknown) { threw = true; + errorMsg = err instanceof Error ? err.message : String(err); } - assertTrue(!threw, "does not throw on nothing-to-commit"); + assertTrue(!threw, `safe empty milestone should not throw (got: ${errorMsg})`); // Main log unchanged (only init commit) const mainLog = run("git log --oneline main", repo); @@ -325,6 +328,401 @@ async function main(): Promise { assertTrue(existsSync(join(repo, "skip-checkout.ts")), "skip-checkout.ts merged to main"); } + // ─── Test 7: Repo using `master` as default branch (#1668) ──────── + console.log("\n=== master-branch repo — no META.json, no prefs (#1668) ==="); + { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-ms-master-test-"))); + tempDirs.push(dir); + run("git init -b master", dir); + run("git config user.email test@test.com", dir); + run("git config user.name Test", dir); + writeFileSync(join(dir, "README.md"), "# master-branch repo\n"); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n"); + run("git add .", dir); + run("git commit -m init", dir); + const defaultBranch = run("git rev-parse --abbrev-ref HEAD", dir); + assertEq(defaultBranch, "master", "repo is on master branch"); + + const wtPath = createAutoWorktree(dir, "M070"); + addSliceToMilestone(dir, wtPath, "M070", "S01", "Master branch test", [ + { file: "master-feature.ts", content: "export const masterFeature = true;\n", message: "add master feature" }, + ]); + + const metaFile = join(dir, ".gsd", "milestones", "M070", "M070-META.json"); + assertTrue(!existsSync(metaFile), "no META.json — integration branch not captured"); + + const roadmap = makeRoadmap("M070", "Master branch milestone", [ + { id: "S01", title: "Master branch test" }, + ]); + + let threw = false; + let errMsg = ""; + try { + const result = mergeMilestoneToMain(dir, "M070", roadmap); + assertTrue(result.commitMessage.includes("feat(M070)"), "merge commit created on master"); + } catch (err) { + threw = true; + errMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(!threw, `should not throw on master-branch repo (got: ${errMsg})`); + + const finalBranch = run("git rev-parse --abbrev-ref HEAD", dir); + assertEq(finalBranch, "master", "repo is still on master after merge"); + assertTrue(existsSync(join(dir, "master-feature.ts")), "feature merged to master"); + const branches = run("git branch", dir); + assertTrue(!branches.includes("milestone/M070"), "milestone branch deleted after merge"); + } + + // ─── Test 8: #1738 Bug 1 — dirty working tree detected by nativeMergeSquash ── + console.log("\n=== #1738 bug 1: nativeMergeSquash detects dirty working tree ==="); + { + const { nativeMergeSquash } = await import("../native-git-bridge.ts"); + const repo = freshRepo(); + + run("git checkout -b milestone/M070", repo); + writeFileSync(join(repo, "feature.ts"), "export const feature = true;\n"); + run("git add .", repo); + run('git commit -m "add feature"', repo); + run("git checkout main", repo); + + writeFileSync(join(repo, "feature.ts"), "// local dirty version\n"); + + const result = nativeMergeSquash(repo, "milestone/M070"); + assertEq(result.success, false, "merge reports failure on dirty working tree"); + assertTrue( + result.conflicts.includes("__dirty_working_tree__"), + "conflicts include __dirty_working_tree__ sentinel", + ); + + run("git checkout -- . 2>/dev/null || true", repo); + run("rm -f feature.ts", repo); + } + + // ─── Test 9: #1738 Bug 2 — branch preserved on empty squash commit ── + console.log("\n=== #1738 bug 2: branch preserved when squash commit empty ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M080"); + + // Make no changes — squash will produce nothing to commit + const roadmap = makeRoadmap("M080", "Empty milestone", []); + + // With the #1792 anchor check, empty milestones with no code changes + // are safe to proceed — no data to lose. + let threw = false; + let errMsg = ""; + try { + mergeMilestoneToMain(repo, "M080", roadmap); + } catch (err: unknown) { + threw = true; + errMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(!threw, `empty milestone with no code changes should not throw (got: ${errMsg})`); + } + + // ─── Test 10: #1738 Bug 3 — clearProjectRootStateFiles cleans synced dirs ── + console.log("\n=== #1738 bug 3: synced .gsd/ dirs cleaned before merge ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M090"); + + addSliceToMilestone(repo, wtPath, "M090", "S01", "Sync test", [ + { file: "sync-test.ts", content: "export const sync = true;\n", message: "add sync-test" }, + ]); + + // Simulate syncStateToProjectRoot: create untracked .gsd/ milestone files + const msDir = join(repo, ".gsd", "milestones", "M090", "slices", "S01"); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "S01-PLAN.md"), "# synced plan\n"); + writeFileSync( + join(repo, ".gsd", "milestones", "M090", "M090-ROADMAP.md"), + "# synced roadmap\n", + ); + + const runtimeDir = join(repo, ".gsd", "runtime", "units"); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync(join(runtimeDir, "unit-001.json"), '{"stale": true}'); + + const roadmap = makeRoadmap("M090", "Sync cleanup test", [ + { id: "S01", title: "Sync test" }, + ]); + + let threw = false; + try { + const result = mergeMilestoneToMain(repo, "M090", roadmap); + assertTrue( + result.commitMessage.includes("feat(M090)"), + "#1738 merge succeeds after cleaning synced dirs", + ); + } catch (err: unknown) { + threw = true; + console.error("#1738 bug 3 regression:", err); + } + assertTrue(!threw, "#1738 merge does not fail on synced .gsd/ files"); + assertTrue(existsSync(join(repo, "sync-test.ts")), "sync-test.ts on main after merge"); + } + + // ─── Test 11: #1738 Bug 1+2 — dirty tree merge preserves branch end-to-end ── + console.log("\n=== #1738 e2e: dirty tree rejection preserves branch ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M100"); + + addSliceToMilestone(repo, wtPath, "M100", "S01", "E2E test", [ + { file: "e2e.ts", content: "export const e2e = true;\n", message: "add e2e" }, + ]); + + writeFileSync(join(repo, "e2e.ts"), "// conflicting local file\n"); + + const roadmap = makeRoadmap("M100", "E2E dirty tree", [ + { id: "S01", title: "E2E test" }, + ]); + + let threw = false; + let errorMsg = ""; + try { + mergeMilestoneToMain(repo, "M100", roadmap); + } catch (err: unknown) { + threw = true; + errorMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(threw, "#1738 e2e: throws on dirty working tree"); + assertTrue( + errorMsg.includes("dirty") || errorMsg.includes("untracked") || errorMsg.includes("overwritten"), + "#1738 e2e: error identifies dirty tree cause", + ); + + const branches = run("git branch", repo); + assertTrue( + branches.includes("milestone/M100"), + "#1738 e2e: milestone branch preserved on dirty tree rejection", + ); + } + + // ─── Test 12: Throw on unanchored code changes after empty commit (#1792) ─ + console.log("\n=== throw on unanchored code changes after empty commit (#1792) ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M120"); + + addSliceToMilestone(repo, wtPath, "M120", "S01", "Critical feature", [ + { file: "critical.ts", content: "export const critical = true;\n", message: "add critical feature" }, + ]); + + // Simulate: merge then revert — git considers branch "already merged" + // but code is NOT on main (reverted). + run(`git merge milestone/M120 --no-ff -m "merge M120"`, repo); + run("git revert HEAD --no-edit -m 1", repo); + + const roadmap = makeRoadmap("M120", "Critical milestone", [ + { id: "S01", title: "Critical feature" }, + ]); + + let threw = false; + let errMsg = ""; + try { + mergeMilestoneToMain(repo, "M120", roadmap); + } catch (err) { + threw = true; + errMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(threw, "throws when milestone has unanchored code changes (#1792)"); + assertTrue( + errMsg.includes("code file(s) not on"), + "error message mentions unanchored code files (#1792)", + ); + + const branches = run("git branch", repo); + assertTrue( + branches.includes("milestone/M120"), + "milestone branch preserved when code is unanchored (#1792)", + ); + } + + // ─── Test 13: Safe teardown when nothing-to-commit and work already on main (#1792) ─ + console.log("\n=== safe teardown — nothing to commit, work already on main (#1792) ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M130"); + + addSliceToMilestone(repo, wtPath, "M130", "S01", "Already landed", [ + { file: "landed.ts", content: "export const landed = true;\n", message: "add landed feature" }, + ]); + + run("git merge --squash milestone/M130", repo); + run('git commit -m "pre-land milestone work"', repo); + + const roadmap = makeRoadmap("M130", "Pre-landed milestone", [ + { id: "S01", title: "Already landed" }, + ]); + + let threw = false; + let errMsg = ""; + try { + mergeMilestoneToMain(repo, "M130", roadmap); + } catch (err) { + threw = true; + errMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(!threw, `safe nothing-to-commit should not throw (got: ${errMsg})`); + assertTrue(existsSync(join(repo, "landed.ts")), "landed.ts present on main"); + } + + // ─── Test 14: Stale branch ref — worktree HEAD ahead of branch (#1846) ─ + console.log("\n=== stale branch ref — fast-forward before squash merge (#1846) ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M140"); + + // Add a first slice normally — this advances both the branch ref and HEAD + addSliceToMilestone(repo, wtPath, "M140", "S01", "Initial work", [ + { file: "initial.ts", content: "export const initial = true;\n", message: "add initial" }, + ]); + + // Now simulate the bug: detach HEAD in the worktree, then make commits + // that advance HEAD but leave the milestone/M140 branch ref behind. + const branchRefBefore = run("git rev-parse milestone/M140", wtPath); + run("git checkout --detach HEAD", wtPath); + + // Add multiple commits on the detached HEAD (simulates agent work) + writeFileSync(join(wtPath, "feature-a.ts"), "export const featureA = true;\n"); + run("git add .", wtPath); + run('git commit -m "add feature-a"', wtPath); + + writeFileSync(join(wtPath, "feature-b.ts"), "export const featureB = true;\n"); + run("git add .", wtPath); + run('git commit -m "add feature-b"', wtPath); + + writeFileSync(join(wtPath, "feature-c.ts"), "export const featureC = true;\n"); + run("git add .", wtPath); + run('git commit -m "add feature-c"', wtPath); + + // Verify: branch ref is stale, HEAD is ahead + const branchRefAfter = run("git rev-parse milestone/M140", wtPath); + const worktreeHead = run("git rev-parse HEAD", wtPath); + assertEq(branchRefBefore, branchRefAfter, "branch ref unchanged (stale)"); + assertTrue(worktreeHead !== branchRefAfter, "worktree HEAD ahead of branch ref"); + + const roadmap = makeRoadmap("M140", "Stale ref milestone", [ + { id: "S01", title: "Initial work" }, + ]); + + // The fix should fast-forward the branch ref to worktree HEAD before + // squash-merging, so ALL commits are captured. + let threw = false; + let errMsg = ""; + try { + const result = mergeMilestoneToMain(repo, "M140", roadmap); + assertTrue(result.commitMessage.includes("feat(M140)"), "merge commit created"); + } catch (err) { + threw = true; + errMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(!threw, `should not throw with stale branch ref (got: ${errMsg})`); + + // ALL files from detached HEAD commits must be on main — not just + // the ones from the stale branch ref + assertTrue(existsSync(join(repo, "initial.ts")), "initial.ts on main"); + assertTrue(existsSync(join(repo, "feature-a.ts")), "feature-a.ts on main (#1846)"); + assertTrue(existsSync(join(repo, "feature-b.ts")), "feature-b.ts on main (#1846)"); + assertTrue(existsSync(join(repo, "feature-c.ts")), "feature-c.ts on main (#1846)"); + } + + // ─── Test 15: Diverged worktree HEAD — throws instead of losing data (#1846) ─ + console.log("\n=== diverged worktree HEAD — throws on divergence (#1846) ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M150"); + + addSliceToMilestone(repo, wtPath, "M150", "S01", "Base work", [ + { file: "base.ts", content: "export const base = true;\n", message: "add base" }, + ]); + + run("git checkout --detach HEAD", wtPath); + writeFileSync(join(wtPath, "detached-work.ts"), "export const detached = true;\n"); + run("git add .", wtPath); + run('git commit -m "detached work"', wtPath); + + run("git checkout milestone/M150", repo); + writeFileSync(join(repo, "diverged-work.ts"), "export const diverged = true;\n"); + run("git add .", repo); + run('git commit -m "diverged work on branch"', repo); + run("git checkout main", repo); + + process.chdir(wtPath); + + const roadmap = makeRoadmap("M150", "Diverged milestone", [ + { id: "S01", title: "Base work" }, + ]); + + let threw = false; + let errMsg = ""; + try { + mergeMilestoneToMain(repo, "M150", roadmap); + } catch (err) { + threw = true; + errMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(threw, "throws when worktree HEAD diverged from branch ref (#1846)"); + assertTrue(errMsg.includes("diverged"), "error message mentions divergence (#1846)"); + + const branches = run("git branch", repo); + assertTrue(branches.includes("milestone/M150"), "milestone branch preserved on divergence (#1846)"); + } + + // ─── Test 16: #1853 Bug 1 — SQUASH_MSG cleaned up after squash-merge ── + console.log("\n=== #1853 bug 1: SQUASH_MSG cleaned up after successful squash-merge ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M160"); + + addSliceToMilestone(repo, wtPath, "M160", "S01", "SQUASH_MSG cleanup test", [ + { file: "squash-cleanup.ts", content: "export const cleanup = true;\n", message: "add squash-cleanup" }, + ]); + + const roadmap = makeRoadmap("M160", "SQUASH_MSG cleanup", [ + { id: "S01", title: "SQUASH_MSG cleanup test" }, + ]); + + const squashMsgPath = join(repo, ".git", "SQUASH_MSG"); + writeFileSync(squashMsgPath, "leftover squash message\n"); + assertTrue(existsSync(squashMsgPath), "SQUASH_MSG planted before merge"); + + const result = mergeMilestoneToMain(repo, "M160", roadmap); + assertTrue(result.commitMessage.includes("feat(M160)"), "merge commit created"); + + assertTrue( + !existsSync(squashMsgPath), + "#1853: SQUASH_MSG must not persist after successful squash-merge", + ); + } + + // ─── Test 17: #1853 Bug 2 — uncommitted worktree code survives teardown ── + console.log("\n=== #1853 bug 2: uncommitted worktree changes committed before teardown ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M170"); + + addSliceToMilestone(repo, wtPath, "M170", "S01", "Teardown safety test", [ + { file: "safe-file.ts", content: "export const safe = true;\n", message: "add safe file" }, + ]); + + writeFileSync(join(wtPath, "uncommitted-agent-code.ts"), "export const lost = true;\n"); + + const roadmap = makeRoadmap("M170", "Teardown safety", [ + { id: "S01", title: "Teardown safety test" }, + ]); + + const result = mergeMilestoneToMain(repo, "M170", roadmap); + assertTrue(result.commitMessage.includes("feat(M170)"), "merge commit created"); + + assertTrue( + existsSync(join(repo, "uncommitted-agent-code.ts")), + "#1853: uncommitted worktree code must survive teardown", + ); + } + } finally { process.chdir(savedCwd); for (const d of tempDirs) { diff --git a/src/resources/extensions/gsd/tests/auto-worktree.test.ts b/src/resources/extensions/gsd/tests/auto-worktree.test.ts index cb21d4f2b..1966c00bf 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree.test.ts @@ -172,6 +172,29 @@ async function main(): Promise { teardownAutoWorktree(tempDir, "M005"); } + // ─── #1713: stale worktree directory recovery ───────────────────── + console.log("\n=== #1713: stale worktree directory without .git file ==="); + { + // Simulate a crash leaving a stale directory with no .git file. + // createAutoWorktree should detect and remove the stale directory, + // then successfully create a fresh worktree. + const { worktreePath } = await import("../worktree-manager.ts"); + const staleDir = worktreePath(tempDir, "M010"); + mkdirSync(staleDir, { recursive: true }); + // Write a dummy file to prove it's not an empty directory + writeFileSync(join(staleDir, "orphan.txt"), "stale leftover\n"); + assertTrue(existsSync(staleDir), "stale directory exists before recovery"); + assertTrue(!existsSync(join(staleDir, ".git")), "stale directory has no .git file"); + + // createAutoWorktree should remove the stale dir and create a real worktree + const recoveredPath = createAutoWorktree(tempDir, "M010"); + assertTrue(existsSync(recoveredPath), "worktree created after stale dir recovery"); + assertTrue(existsSync(join(recoveredPath, ".git")), "recovered worktree has .git file"); + assertTrue(!existsSync(join(recoveredPath, "orphan.txt")), "stale file removed by recovery"); + + teardownAutoWorktree(tempDir, "M010"); + } + // ─── #778: reconcile plan checkboxes on re-attach ───────────────── console.log("\n=== #778: reconcile plan checkboxes on re-attach ==="); { diff --git a/src/resources/extensions/gsd/tests/autocomplete-regressions-1675.test.ts b/src/resources/extensions/gsd/tests/autocomplete-regressions-1675.test.ts new file mode 100644 index 000000000..22e1528db --- /dev/null +++ b/src/resources/extensions/gsd/tests/autocomplete-regressions-1675.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { registerGSDCommand } from "../commands.ts"; +import { handleGSDCommand } from "../commands/dispatcher.ts"; + +function createMockPi() { + const commands = new Map(); + return { + registerCommand(name: string, options: any) { + commands.set(name, options); + }, + registerTool() {}, + registerShortcut() {}, + on() {}, + sendMessage() {}, + commands, + }; +} + +function createMockCtx() { + const notifications: { message: string; level: string }[] = []; + return { + notifications, + ui: { + notify(message: string, level: string) { + notifications.push({ message, level }); + }, + custom: async () => {}, + }, + shutdown: async () => {}, + }; +} + +test("/gsd description includes discuss", () => { + const pi = createMockPi(); + registerGSDCommand(pi as any); + + const gsd = pi.commands.get("gsd"); + assert.ok(gsd, "registerGSDCommand should register /gsd"); + assert.ok( + gsd.description.includes("discuss"), + "description should include discuss", + ); +}); + +test("/gsd next completions include --debug", () => { + const pi = createMockPi(); + registerGSDCommand(pi as any); + + const gsd = pi.commands.get("gsd"); + const completions = gsd.getArgumentCompletions("next "); + const debug = completions.find((c: any) => c.value === "next --debug"); + assert.ok(debug, "next --debug should appear in completions"); +}); + +test("/gsd widget completions include full|small|min|off", () => { + const pi = createMockPi(); + registerGSDCommand(pi as any); + + const gsd = pi.commands.get("gsd"); + const completions = gsd.getArgumentCompletions("widget "); + const values = completions.map((c: any) => c.value); + for (const expected of ["widget full", "widget small", "widget min", "widget off"]) { + assert.ok(values.includes(expected), `missing completion: ${expected}`); + } +}); + +test("bare /gsd skip shows usage and does not fall through to unknown-command warning", async () => { + const ctx = createMockCtx(); + + await handleGSDCommand("skip", ctx as any, {} as any); + + assert.ok( + ctx.notifications.some((n) => n.message.includes("Usage: /gsd skip ")), + "should show skip usage guidance", + ); + assert.ok( + !ctx.notifications.some((n) => n.message.startsWith("Unknown: /gsd skip")), + "should not emit unknown-command warning for bare skip", + ); +}); + diff --git a/src/resources/extensions/gsd/tests/browser-teardown.test.ts b/src/resources/extensions/gsd/tests/browser-teardown.test.ts new file mode 100644 index 000000000..379940ae5 --- /dev/null +++ b/src/resources/extensions/gsd/tests/browser-teardown.test.ts @@ -0,0 +1,133 @@ +/** + * browser-teardown.test.ts — Verifies browser cleanup at unit boundaries (#1733). + * + * Tests that the browser-tools lifecycle module is correctly called to tear + * down Chrome/Playwright processes during stopAuto() and between units. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +// Direct imports of browser-tools state to verify teardown behavior +import { + getBrowser, + setBrowser, + getContext, + setContext, + resetAllState, +} from "../../browser-tools/state.ts"; +import { closeBrowser } from "../../browser-tools/lifecycle.ts"; + +// ─── closeBrowser clears state ────────────────────────────────────────────── + +test("closeBrowser resets browser state even when no browser is running", async () => { + // Ensure clean state + resetAllState(); + assert.equal(getBrowser(), null, "browser should be null initially"); + assert.equal(getContext(), null, "context should be null initially"); + + // closeBrowser should be safe to call with no active browser + await closeBrowser(); + + assert.equal(getBrowser(), null, "browser should remain null after closeBrowser"); + assert.equal(getContext(), null, "context should remain null after closeBrowser"); +}); + +test("closeBrowser calls browser.close() and resets all state", async () => { + resetAllState(); + + let closeCalled = false; + const fakeBrowser = { + close: async () => { closeCalled = true; }, + } as any; + + setBrowser(fakeBrowser); + setContext({ /* fake context */ } as any); + + assert.ok(getBrowser(), "browser should be set before teardown"); + assert.ok(getContext(), "context should be set before teardown"); + + await closeBrowser(); + + assert.equal(closeCalled, true, "browser.close() should have been called"); + assert.equal(getBrowser(), null, "browser should be null after teardown"); + assert.equal(getContext(), null, "context should be null after teardown"); +}); + +// ─── getBrowser guard pattern ─────────────────────────────────────────────── + +test("getBrowser() guard prevents unnecessary closeBrowser calls", async () => { + resetAllState(); + + // This is the pattern used in stopAuto and postUnitPreVerification: + // if (getBrowser()) { await closeBrowser(); } + // Verify the guard works correctly when no browser is active. + + let teardownAttempted = false; + if (getBrowser()) { + await closeBrowser(); + teardownAttempted = true; + } + + assert.equal(teardownAttempted, false, "should not attempt teardown when no browser is active"); +}); + +test("getBrowser() guard triggers closeBrowser when browser is active", async () => { + resetAllState(); + + let closeCalled = false; + setBrowser({ + close: async () => { closeCalled = true; }, + } as any); + + let teardownAttempted = false; + if (getBrowser()) { + await closeBrowser(); + teardownAttempted = true; + } + + assert.equal(teardownAttempted, true, "should attempt teardown when browser is active"); + assert.equal(closeCalled, true, "browser.close() should have been called"); + assert.equal(getBrowser(), null, "browser should be null after guarded teardown"); +}); + +// ─── Source code verification ─────────────────────────────────────────────── + +test("stopAuto finally block includes browser teardown", async () => { + // Verify the source code contains the browser teardown call + const { readFileSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const autoSource = readFileSync(resolve(import.meta.dirname, "..", "auto.ts"), "utf-8"); + + assert.ok( + autoSource.includes("closeBrowser"), + "auto.ts should reference closeBrowser for teardown in stopAuto", + ); + assert.ok( + autoSource.includes("getBrowser"), + "auto.ts should check getBrowser() before calling closeBrowser", + ); + assert.ok( + autoSource.includes("browser-tools/lifecycle"), + "auto.ts should import from browser-tools/lifecycle", + ); +}); + +test("postUnitPreVerification includes browser teardown between units", async () => { + const { readFileSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const postUnitSource = readFileSync(resolve(import.meta.dirname, "..", "auto-post-unit.ts"), "utf-8"); + + assert.ok( + postUnitSource.includes("closeBrowser"), + "auto-post-unit.ts should reference closeBrowser for inter-unit teardown", + ); + assert.ok( + postUnitSource.includes("getBrowser"), + "auto-post-unit.ts should check getBrowser() before calling closeBrowser", + ); + assert.ok( + postUnitSource.includes("browser-teardown"), + "auto-post-unit.ts should have browser-teardown debug phase", + ); +}); diff --git a/src/resources/extensions/gsd/tests/context-store.test.ts b/src/resources/extensions/gsd/tests/context-store.test.ts index 0896e86c2..a3f256d91 100644 --- a/src/resources/extensions/gsd/tests/context-store.test.ts +++ b/src/resources/extensions/gsd/tests/context-store.test.ts @@ -51,17 +51,17 @@ console.log('\n=== context-store: query all active decisions ==='); insertDecision({ id: 'D001', when_context: 'M001/S01', scope: 'architecture', decision: 'use SQLite', choice: 'node:sqlite', rationale: 'built-in', - revisable: 'yes', superseded_by: 'D003', // superseded! + revisable: 'yes', made_by: 'agent', superseded_by: 'D003', // superseded! }); insertDecision({ id: 'D002', when_context: 'M001/S01', scope: 'architecture', decision: 'use WAL mode', choice: 'WAL', rationale: 'concurrent reads', - revisable: 'no', superseded_by: null, + revisable: 'no', made_by: 'agent', superseded_by: null, }); insertDecision({ id: 'D003', when_context: 'M002/S01', scope: 'performance', decision: 'use better-sqlite3', choice: 'better-sqlite3', rationale: 'faster', - revisable: 'yes', superseded_by: null, + revisable: 'yes', made_by: 'agent', superseded_by: null, }); const all = queryDecisions(); @@ -81,11 +81,13 @@ console.log('\n=== context-store: query decisions by milestone ==='); insertDecision({ id: 'D001', when_context: 'M001/S01', scope: 'architecture', decision: 'decision A', choice: 'A', rationale: 'r', revisable: 'yes', + made_by: 'agent', superseded_by: null, }); insertDecision({ id: 'D002', when_context: 'M002/S02', scope: 'architecture', decision: 'decision B', choice: 'B', rationale: 'r', revisable: 'yes', + made_by: 'agent', superseded_by: null, }); @@ -107,11 +109,13 @@ console.log('\n=== context-store: query decisions by scope ==='); insertDecision({ id: 'D001', when_context: 'M001/S01', scope: 'architecture', decision: 'decision A', choice: 'A', rationale: 'r', revisable: 'yes', + made_by: 'agent', superseded_by: null, }); insertDecision({ id: 'D002', when_context: 'M001/S01', scope: 'performance', decision: 'decision B', choice: 'B', rationale: 'r', revisable: 'yes', + made_by: 'agent', superseded_by: null, }); @@ -248,12 +252,12 @@ console.log('\n=== context-store: formatDecisionsForPrompt ==='); { seq: 1, id: 'D001', when_context: 'M001/S01', scope: 'architecture', decision: 'use SQLite', choice: 'node:sqlite', rationale: 'built-in', - revisable: 'yes', superseded_by: null, + revisable: 'yes', made_by: 'agent', superseded_by: null, }, { seq: 2, id: 'D002', when_context: 'M001/S02', scope: 'performance', decision: 'use WAL', choice: 'WAL', rationale: 'concurrent', - revisable: 'no', superseded_by: null, + revisable: 'no', made_by: 'human', superseded_by: null, }, ]); @@ -323,6 +327,7 @@ console.log('\n=== context-store: sub-5ms query timing ==='); choice: `choice ${i}`, rationale: `rationale ${i}`, revisable: i % 3 === 0 ? 'no' : 'yes', + made_by: 'agent', superseded_by: null, }); } diff --git a/src/resources/extensions/gsd/tests/db-writer.test.ts b/src/resources/extensions/gsd/tests/db-writer.test.ts index 44b5caac1..fbde354a0 100644 --- a/src/resources/extensions/gsd/tests/db-writer.test.ts +++ b/src/resources/extensions/gsd/tests/db-writer.test.ts @@ -59,6 +59,7 @@ const SAMPLE_DECISIONS: Decision[] = [ choice: 'better-sqlite3', rationale: 'Sync API', revisable: 'No', + made_by: 'collaborative', superseded_by: null, }, { @@ -70,6 +71,7 @@ const SAMPLE_DECISIONS: Decision[] = [ choice: '.gsd/gsd.db', rationale: 'Derived state', revisable: 'No', + made_by: 'agent', superseded_by: null, }, { @@ -81,6 +83,7 @@ const SAMPLE_DECISIONS: Decision[] = [ choice: 'node:sqlite fallback', rationale: 'Zero deps', revisable: 'Yes', + made_by: 'human', superseded_by: null, }, ]; @@ -166,6 +169,7 @@ console.log('\n── generateDecisionsMd round-trip ──'); assertEq(rt.choice, orig.choice, `decision ${orig.id} choice round-trips`); assertEq(rt.rationale, orig.rationale, `decision ${orig.id} rationale round-trips`); assertEq(rt.revisable, orig.revisable, `decision ${orig.id} revisable round-trips`); + assertEq(rt.made_by, orig.made_by, `decision ${orig.id} made_by round-trips`); } } @@ -177,6 +181,7 @@ console.log('\n── generateDecisionsMd format ──'); assertTrue(md.includes('